feat: v0.8 Iteration A — ID-class fixes, manifest privacy, registry + greetings

A1 — ID-class bug family (v0.6.1 persona split left network-NodeId comparisons
where posting IDs live). ~14 sites fixed with multi-persona semantics ("author is
one of MY posting identities", not == default):
- revoke_post_access / revoke_circle_access (revocation was broken for every post)
- group key create/add-member/rotate (add-member distribution now works)
- circle profile set/delete/get, encrypted-attachment CEK unwrap
- receipt/comment slot authorship, replication cycle, blob eviction own-tier
- orphaned GroupKeyRequest/Response (0xA1/0xA2) deleted (no send side existed)

A2 — Manifest privacy: AuthorManifest.author_addresses removed from struct AND
signature digest; CdnManifest tree-era fields dropped. Startup migrations re-sign
own manifests, purge unverifiable foreign copies, strip persona fields from legacy
network-id profile rows. Posting identities are now location-anonymous.

A3 — Discovery & first contact:
- Open-slot comments: derivable slot V_x (blake3 from author + slot_binder_nonce)
  so strangers pass the CDN comment-verification gate; OpenSlotDecl in FoF gating
- Greetings: HPKE-style sealed GreetingBody (return_path + fresh reply_pubkey),
  throwaway outer ID, size-bucketed; messaging-first (Reply/Dismiss, no vouch)
- Registry: frozen canonical registry post + REGISTRY_POST_ID, signed registration
  comments (self-certifying deletes), newest-wins per persona, --publish-registry
- Comment TTL: expires_at_ms inside signed digest v2 (context bump), rand 30-365d
  ordinary / fixed 30d registrations, expiry sweep on the eviction cycle
- Single accept_incoming_comment() gate wired into all three ingest sites, closing
  a pre-existing hole where both pull paths stored comments with no verification
- UI: consent panel (visible/listed/greetings, active choice at first publish),
  registry search in Discover, sealed "Say hi" compose, greetings inbox

Security fixes from review: forged-tombstone injection, delete-by-sender-trust,
remote SetPolicy poisoning, stored XSS (escapeHtml quotes, 7 pre-existing sites),
greeting off-switch now revokes prior bios' slots.

No PoW (rejected: inverted cost). Session-relay gating untouched.
201 core tests pass; CLI + desktop build; 3-node integration green.

DEPLOY GATE: wire-incompatible with v0.7.3 — ALPN bump to itsgoin/4 lands in
Iteration B. Do not build/deploy/anchor-swap before that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
This commit is contained in:
Scott Reimers 2026-07-30 08:28:26 -04:00
parent 66c9851061
commit 17dbf076cb
19 changed files with 5237 additions and 524 deletions

View file

@ -145,6 +145,8 @@ struct BadgeCountsDto {
unread_messages: usize,
new_reacts: usize,
new_comments: usize,
/// v0.8 (A3): undismissed greetings in the inbox (Messages badge adds this).
greetings: usize,
}
#[derive(Serialize)]
@ -279,7 +281,11 @@ async fn post_to_dto(
// Engagement data
let reaction_counts = {
let storage = node.storage.get().await;
storage.get_reaction_counts(id, &node.node_id).unwrap_or_default()
// Reactors are posting ids — "reacted_by_me" = any of our personas.
let our_ids: Vec<itsgoin_core::types::NodeId> = storage.list_posting_identities()
.unwrap_or_default()
.into_iter().map(|p| p.node_id).collect();
storage.get_reaction_counts(id, &our_ids).unwrap_or_default()
.into_iter()
.map(|(emoji, count, reacted_by_me)| ReactionCountDto { emoji, count, reacted_by_me })
.collect()
@ -555,9 +561,13 @@ async fn list_posting_identities(
async fn create_posting_identity(
state: State<'_, AppNode>,
display_name: String,
greetings_open: Option<bool>,
) -> Result<PostingIdentityDto, String> {
let node = get_node(&state).await;
let id = node.create_posting_identity(display_name).await.map_err(|e| e.to_string())?;
let id = node
.create_posting_identity(display_name, greetings_open)
.await
.map_err(|e| e.to_string())?;
Ok(PostingIdentityDto {
node_id: hex::encode(id.node_id),
display_name: id.display_name,
@ -863,10 +873,12 @@ async fn post_to_dto_batch(
// Batch queries — 3 queries total instead of 4 × N
let (reaction_map, comment_map, intent_map, posting_identities) = {
let storage = node.storage.get().await;
let reactions = storage.get_reaction_counts_batch(&post_ids, &node.node_id).unwrap_or_default();
let identities = storage.list_posting_identities().unwrap_or_default();
// Reactors are posting ids — "reacted_by_me" = any of our personas.
let our_ids: Vec<itsgoin_core::types::NodeId> = identities.iter().map(|p| p.node_id).collect();
let reactions = storage.get_reaction_counts_batch(&post_ids, &our_ids).unwrap_or_default();
let comments = storage.get_comment_counts_batch(&post_ids).unwrap_or_default();
let intents = storage.get_post_intents_batch(&post_ids).unwrap_or_default();
let identities = storage.list_posting_identities().unwrap_or_default();
(reactions, comments, intents, identities)
};
// Map posting-id -> display-name so we can tag author=persona posts.
@ -1729,6 +1741,264 @@ async fn set_session_relay_enabled(state: State<'_, AppNode>, enabled: bool) ->
Ok(())
}
// --- v0.8 (A3): registry search + greetings ---
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RegistryEntryDto {
author_id: String,
display_name: String,
keywords: Vec<String>,
updated_at_ms: u64,
expires_at_ms: u64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RegistryStatusDto {
listed: bool,
keywords: Vec<String>,
expires_at_ms: u64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GreetingDto {
/// Comment key part 1 (throwaway outer identity) — opaque to the UI,
/// passed back verbatim for reply/dismiss.
comment_author: String,
/// Comment key part 2.
post_id: String,
/// Comment key part 3.
timestamp_ms: u64,
/// The sender's REAL persona (recovered from inside the seal).
sender_persona_id: String,
sender_name: String,
content: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GreetingSlotDto {
bio_post_id: String,
accepts_greetings: bool,
}
/// Register the persona in the network registry (30d entry, auto-renews
/// while listed — the core eviction cycle re-signs ~every 25 days).
#[tauri::command]
async fn register_persona(
state: State<'_, AppNode>,
posting_id_hex: String,
name: String,
keywords: Vec<String>,
) -> Result<(), String> {
let node = get_node(&state).await;
let posting_id = parse_node_id(&posting_id_hex)?;
node.register_persona(&posting_id, &name, &keywords)
.await
.map_err(|e| e.to_string())
}
/// Remove the persona's registry entry (self-certifying signed delete).
#[tauri::command]
async fn unregister_persona(
state: State<'_, AppNode>,
posting_id_hex: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let posting_id = parse_node_id(&posting_id_hex)?;
node.unregister_persona(&posting_id).await.map_err(|e| e.to_string())
}
/// Current "Listed" state for the consent panel: listed flag + the
/// keywords used at last registration + entry expiry (0 if none held).
#[tauri::command]
async fn get_registry_status(
state: State<'_, AppNode>,
posting_id_hex: String,
) -> Result<RegistryStatusDto, String> {
let node = get_node(&state).await;
let posting_id = parse_node_id(&posting_id_hex)?;
let storage = node.storage.get().await;
let listed = storage
.get_setting(&format!("registry_listed.{}", posting_id_hex))
.ok()
.flatten()
.map(|v| v == "1")
.unwrap_or(false);
let keywords = storage
.get_setting(&format!("registry_keywords.{}", posting_id_hex))
.ok()
.flatten()
.map(|v| {
v.split(',')
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty())
.collect()
})
.unwrap_or_default();
let expires_at_ms = storage
.get_newest_registry_entry(&itsgoin_core::registry::REGISTRY_POST_ID, &posting_id)
.ok()
.flatten()
.map(|(_ts, exp)| exp)
.unwrap_or(0);
Ok(RegistryStatusDto { listed, keywords, expires_at_ms })
}
/// Search the registry: refreshes the comment chain from peers, then
/// queries locally (search cost lands on the searcher, design §27).
#[tauri::command]
async fn search_registry(
state: State<'_, AppNode>,
query: String,
) -> Result<Vec<RegistryEntryDto>, String> {
let node = get_node(&state).await;
let matches = node.search_registry(&query).await.map_err(|e| e.to_string())?;
Ok(matches
.into_iter()
.map(|m| RegistryEntryDto {
author_id: hex::encode(m.author),
display_name: m.name,
keywords: m.keywords,
updated_at_ms: m.timestamp_ms,
expires_at_ms: m.expires_at_ms,
})
.collect())
}
/// Locally-held greeting-slot info for an author's latest bio post.
async fn greeting_slot_local(node: &Node, author: &NodeId) -> Option<GreetingSlotDto> {
let storage = node.storage.get().await;
let bio_post_id = storage
.get_latest_profile_post_id_by_author(author)
.ok()
.flatten()?;
let post = storage.get_post(&bio_post_id).ok().flatten()?;
let accepts = post
.fof_gating
.as_ref()
.and_then(|g| g.open_slot.as_ref())
.map(|d| d.kind == itsgoin_core::types::OpenSlotKind::Greeting)
.unwrap_or(false);
Some(GreetingSlotDto {
bio_post_id: hex::encode(bio_post_id),
accepts_greetings: accepts,
})
}
/// Does this author's bio declare a Greeting open slot? Drives the
/// "Say hi" button in the bio modal. On-demand (A3 §3.2): if the bio
/// isn't local yet (e.g. a registry search result), worm-locate the
/// author and sync their posts once before answering.
#[tauri::command]
async fn get_greeting_slot(
state: State<'_, AppNode>,
node_id_hex: String,
) -> Result<Option<GreetingSlotDto>, String> {
let node = get_node(&state).await;
let nid = parse_node_id(&node_id_hex)?;
if let Some(dto) = greeting_slot_local(&node, &nid).await {
return Ok(Some(dto));
}
// Fetch-on-demand: existing worm lookup + one-shot sync, then retry.
if let Ok(Some(wr)) = node.worm_lookup(&nid).await {
let _ = node.sync_with(wr.node_id).await;
return Ok(greeting_slot_local(&node, &nid).await);
}
Ok(None)
}
/// Send a sealed first-contact greeting to a bio post's author.
/// Messaging-first: no vouch is involved anywhere in this flow.
#[tauri::command]
async fn send_greeting(
state: State<'_, AppNode>,
bio_post_id_hex: String,
content: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let bio_post_id = hex_to_postid(&bio_post_id_hex)?;
node.send_greeting(bio_post_id, content).await.map_err(|e| e.to_string())
}
/// Reply to a received greeting — sealed to the greeting's fresh reply
/// key and dropped into its declared return path (never a long-term key).
#[tauri::command]
async fn reply_to_greeting(
state: State<'_, AppNode>,
comment_author_hex: String,
post_id_hex: String,
timestamp_ms: u64,
content: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let comment_author = parse_node_id(&comment_author_hex)?;
let post_id = hex_to_postid(&post_id_hex)?;
node.reply_to_greeting(comment_author, post_id, timestamp_ms, content)
.await
.map_err(|e| e.to_string())
}
/// Unsealed greetings (and replies) across all personas' bio posts,
/// newest first. Return-path/reply-key internals stay inside core.
#[tauri::command]
async fn list_greetings(state: State<'_, AppNode>) -> Result<Vec<GreetingDto>, String> {
let node = get_node(&state).await;
let records = node.list_greetings().await.map_err(|e| e.to_string())?;
Ok(records
.into_iter()
.map(|g| GreetingDto {
comment_author: hex::encode(g.comment_author),
post_id: hex::encode(g.post_id),
timestamp_ms: g.timestamp_ms,
sender_persona_id: hex::encode(g.sender_persona),
sender_name: g.sender_name,
content: g.text,
})
.collect())
}
/// Dismiss a greeting (local only — nothing propagates).
#[tauri::command]
async fn dismiss_greeting(
state: State<'_, AppNode>,
comment_author_hex: String,
post_id_hex: String,
timestamp_ms: u64,
) -> Result<(), String> {
let node = get_node(&state).await;
let comment_author = parse_node_id(&comment_author_hex)?;
let post_id = hex_to_postid(&post_id_hex)?;
node.dismiss_greeting(comment_author, post_id, timestamp_ms)
.await
.map_err(|e| e.to_string())
}
/// Per-persona greeting consent (republishes the bio on change).
#[tauri::command]
async fn set_greetings_open(
state: State<'_, AppNode>,
posting_id_hex: String,
open: bool,
) -> Result<(), String> {
let node = get_node(&state).await;
let posting_id = parse_node_id(&posting_id_hex)?;
node.set_greetings_open(&posting_id, open).await.map_err(|e| e.to_string())
}
/// Current greeting-consent state (unset = ON, the pre-checked default).
#[tauri::command]
async fn get_greetings_open(
state: State<'_, AppNode>,
posting_id_hex: String,
) -> Result<bool, String> {
let node = get_node(&state).await;
let posting_id = parse_node_id(&posting_id_hex)?;
node.get_greetings_open(&posting_id).await.map_err(|e| e.to_string())
}
/// Open a URL in the user's default system browser.
/// Desktop: spawns the platform opener (xdg-open / open / cmd start).
/// Only https:// URLs are accepted to avoid being a generic command exec.
@ -2063,6 +2333,28 @@ async fn get_seen_engagement(
}))
}
/// Comment count for the engagement badges, EXCLUDING greeting open-slot
/// comments: greetings have their own dedicated badge/inbox, and counting
/// them here would double-count every greeting into "My Posts" while
/// pointing the user at a bio post where the comment renders as sealed
/// ciphertext instead of at the Greetings inbox.
fn non_greeting_comment_count(
storage: &itsgoin_core::storage::Storage,
post_id: &itsgoin_core::types::PostId,
post: &itsgoin_core::types::Post,
) -> u64 {
let mut count = storage.get_comment_count(post_id).unwrap_or(0);
if let Some(decl) = post.fof_gating.as_ref().and_then(|g| g.open_slot.as_ref()) {
if matches!(decl.kind, itsgoin_core::types::OpenSlotKind::Greeting) {
let greetings = storage
.count_unexpired_open_slot_comments(post_id, decl.slot_index)
.unwrap_or(0);
count = count.saturating_sub(greetings);
}
}
count
}
#[tauri::command]
async fn get_badge_counts(
state: State<'_, AppNode>,
@ -2071,11 +2363,17 @@ async fn get_badge_counts(
let node = get_node(&state).await;
let storage = node.storage.get().await;
// "Own post" checks below are posting-class: posts are authored by our
// posting identities (personas), never the network NodeId.
let own_ids: Vec<itsgoin_core::types::NodeId> = storage.list_posting_identities()
.unwrap_or_default()
.into_iter().map(|p| p.node_id).collect();
// Feed badge: count non-DM posts from others newer than last_feed_view_ms
let feed_posts = storage.get_feed().map_err(|e| e.to_string())?;
let new_feed = feed_posts.iter()
.filter(|(id, p, _vis)| {
p.author != node.node_id
!own_ids.contains(&p.author)
&& p.timestamp_ms > last_feed_view_ms
&& !matches!(
storage.get_post_intent(id).ok().flatten(),
@ -2088,18 +2386,20 @@ async fn get_badge_counts(
let all_posts = storage.list_posts_reverse_chron().map_err(|e| e.to_string())?;
let mut new_engagement = 0usize;
for (id, post, _vis) in &all_posts {
if post.author != node.node_id { continue; }
if !own_ids.contains(&post.author) { continue; }
// Skip DMs
if matches!(
storage.get_post_intent(id).ok().flatten(),
Some(VisibilityIntent::Direct(_))
) { continue; }
let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id)
let total_reacts: u64 = storage.get_reaction_counts(id, &own_ids)
.unwrap_or_default()
.iter()
.map(|(_, count, _)| *count)
.sum();
let total_comments = storage.get_comment_count(id).unwrap_or(0);
// Greeting open-slot comments are excluded — they have their own
// dedicated Greetings badge (see non_greeting_comment_count).
let total_comments = non_greeting_comment_count(&storage, id, post);
if total_reacts > 0 || total_comments > 0 {
let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0));
if total_reacts > seen_r as u64 || total_comments > seen_c as u64 {
@ -2114,14 +2414,14 @@ async fn get_badge_counts(
matches!(
storage.get_post_intent(id).ok().flatten(),
Some(VisibilityIntent::Direct(_))
) || (p.author != node.node_id && matches!(
) || (!own_ids.contains(&p.author) && matches!(
storage.get_post_with_visibility(id).ok().flatten(),
Some((_, PostVisibility::Encrypted { .. }))
))
});
let mut seen_partners = std::collections::HashSet::new();
for (_id, post, _vis) in dm_posts {
let partner = if post.author == node.node_id {
let partner = if own_ids.contains(&post.author) {
// sent DM — skip for unread count
continue;
} else {
@ -2139,16 +2439,22 @@ async fn get_badge_counts(
let mut new_reacts = 0usize;
let mut new_comments = 0usize;
for (id, post, _vis) in &all_posts {
if post.author != node.node_id { continue; }
let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id)
if !own_ids.contains(&post.author) { continue; }
let total_reacts: u64 = storage.get_reaction_counts(id, &own_ids)
.unwrap_or_default().iter().map(|(_, c, _)| *c).sum();
let total_comments = storage.get_comment_count(id).unwrap_or(0);
// Greeting open-slot comments excluded here too (dedicated badge).
let total_comments = non_greeting_comment_count(&storage, id, post);
let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0));
if total_reacts > seen_r as u64 { new_reacts += (total_reacts - seen_r as u64) as usize; }
if total_comments > seen_c as u64 { new_comments += (total_comments - seen_c as u64) as usize; }
}
Ok(BadgeCountsDto { new_feed, new_engagement, unread_messages, new_reacts, new_comments })
// v0.8 (A3): greetings inbox size. Release the storage slot first —
// list_greetings takes its own guard internally.
drop(storage);
let greetings = node.list_greetings().await.map(|g| g.len()).unwrap_or(0);
Ok(BadgeCountsDto { new_feed, new_engagement, unread_messages, new_reacts, new_comments, greetings })
}
#[tauri::command]
@ -2994,6 +3300,8 @@ async fn export_data(
}
}
};
// node_id here is only used for the export file name; the post filter
// inside export_data now iterates all posting identities itself.
let result = itsgoin_core::export::export_data(
&node.data_dir,
&node.storage,
@ -3083,11 +3391,13 @@ async fn import_public_posts(
zip_path: String,
) -> Result<String, String> {
let node = get_node(&state).await;
// Imported posts are re-authored as US — that must be a POSTING identity
// (the network NodeId never authors posts).
let result = itsgoin_core::import::import_public_posts(
std::path::Path::new(&zip_path),
&node.storage,
&node.blob_store,
&node.node_id,
&node.default_posting_id,
).await.map_err(|e| e.to_string())?;
Ok(result.message)
}
@ -3141,12 +3451,14 @@ async fn import_merge_with_key(
key_hex: String,
) -> Result<String, String> {
let node = get_node(&state).await;
// Merged content is authored under a POSTING identity, never the
// network NodeId.
let result = itsgoin_core::import::merge_with_key(
std::path::Path::new(&zip_path),
&key_hex,
&node.storage,
&node.blob_store,
&node.node_id,
&node.default_posting_id,
&node.secret_seed(),
).await.map_err(|e| e.to_string())?;
Ok(result.message)
@ -3414,6 +3726,17 @@ pub fn run() {
exit_app,
get_session_relay_enabled,
set_session_relay_enabled,
register_persona,
unregister_persona,
get_registry_status,
search_registry,
get_greeting_slot,
send_greeting,
reply_to_greeting,
list_greetings,
dismiss_greeting,
set_greetings_open,
get_greetings_open,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")