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:
parent
66c9851061
commit
17dbf076cb
19 changed files with 5237 additions and 524 deletions
|
|
@ -27,6 +27,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
let mut web_port: Option<u16> = None;
|
||||
let mut print_identity = false;
|
||||
let mut announce = false;
|
||||
let mut publish_registry = false;
|
||||
let mut ann_category: Option<String> = None;
|
||||
let mut ann_title: Option<String> = None;
|
||||
let mut ann_body: Option<String> = None;
|
||||
|
|
@ -76,6 +77,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
}
|
||||
"--print-identity" => { print_identity = true; i += 1; }
|
||||
"--announce" => { announce = true; i += 1; }
|
||||
"--publish-registry" => { publish_registry = true; i += 1; }
|
||||
"--ann-category" => { ann_category = args.get(i + 1).cloned(); i += 2; }
|
||||
"--ann-title" => { ann_title = args.get(i + 1).cloned(); i += 2; }
|
||||
"--ann-body" => { ann_body = args.get(i + 1).cloned(); i += 2; }
|
||||
|
|
@ -152,6 +154,29 @@ async fn main() -> anyhow::Result<()> {
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
// One-shot: --publish-registry — genesis publish of the network
|
||||
// registry post. Refuses unless this node's default posting identity
|
||||
// is the bootstrap anchor's (debug builds may override for tests via
|
||||
// ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS=1). Prints the registry post ID
|
||||
// for verification against the shipped constant.
|
||||
if publish_registry {
|
||||
let node = Node::open_with_bind(&data_dir, bind_addr, profile).await?;
|
||||
match node.publish_registry_genesis().await {
|
||||
Ok(post_id) => {
|
||||
println!("registry_post_id: {}", hex::encode(post_id));
|
||||
println!(
|
||||
"shipped_constant: {}",
|
||||
hex::encode(itsgoin_core::registry::REGISTRY_POST_ID)
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to publish registry genesis: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Starting ItsGoin node (data: {}, profile: {:?})...", data_dir, profile);
|
||||
let node = Arc::new(Node::open_with_bind(&data_dir, bind_addr, profile).await?);
|
||||
|
||||
|
|
@ -162,8 +187,9 @@ async fn main() -> anyhow::Result<()> {
|
|||
let addr = node.endpoint_addr();
|
||||
let sockets: Vec<_> = addr.ip_addrs().collect();
|
||||
|
||||
// Show our display name if set
|
||||
let my_name = node.get_display_name(&node.node_id).await.unwrap_or(None);
|
||||
// Show our display name if set (profiles are keyed by POSTING identity,
|
||||
// not the network NodeId).
|
||||
let my_name = node.get_display_name(&node.default_posting_id).await.unwrap_or(None);
|
||||
let name_display = my_name.as_deref().unwrap_or("(not set)");
|
||||
|
||||
println!();
|
||||
|
|
@ -206,6 +232,14 @@ async fn main() -> anyhow::Result<()> {
|
|||
println!(" connections Show mesh connections");
|
||||
println!(" social-routes Show social routing cache");
|
||||
println!(" name <display_name> Set your display name");
|
||||
println!(" register <name> [keywords...] Register default persona in the network registry (30d, re-run to renew)");
|
||||
println!(" unregister Remove your registry entry (signed delete)");
|
||||
println!(" search <keywords> Search the registry (pulls chain, queries locally)");
|
||||
println!(" greet <bio_post_id_hex> <text> Send a sealed greeting to a bio post's author");
|
||||
println!(" greetings List unsealed greetings on your bio (with reply/dismiss index)");
|
||||
println!(" reply <greeting_index> <text> Sealed reply to a greeting's return path (fresh reply key)");
|
||||
println!(" dismiss <greeting_index> Dismiss a greeting (local only)");
|
||||
println!(" greetings-open <on|off> Toggle the greeting slot on your bio (republishes bio)");
|
||||
println!(" stats Show node stats");
|
||||
println!(" export-key Export identity key (KEEP SECRET)");
|
||||
println!(" id Show this node's ID");
|
||||
|
|
@ -254,6 +288,10 @@ async fn main() -> anyhow::Result<()> {
|
|||
let stdin = io::stdin();
|
||||
let reader = stdin.lock();
|
||||
|
||||
// A3: cached greeting list so `reply <n>` / `dismiss <n>` indices
|
||||
// refer to the last `greetings` output.
|
||||
let mut last_greetings: Vec<itsgoin_core::node::GreetingRecord> = Vec::new();
|
||||
|
||||
print!("> ");
|
||||
io::stdout().flush()?;
|
||||
|
||||
|
|
@ -342,9 +380,13 @@ async fn main() -> anyhow::Result<()> {
|
|||
if follows.is_empty() {
|
||||
println!("(not following anyone)");
|
||||
}
|
||||
let own_ids: Vec<itsgoin_core::types::NodeId> = node
|
||||
.list_posting_identities().await.unwrap_or_default()
|
||||
.into_iter().map(|p| p.node_id).collect();
|
||||
for nid in follows {
|
||||
let name = node.get_display_name(&nid).await.unwrap_or(None);
|
||||
let label = if nid == node.node_id { " (you)" } else { "" };
|
||||
// Follows are posting ids — "you" = any of our personas.
|
||||
let label = if own_ids.contains(&nid) { " (you)" } else { "" };
|
||||
if let Some(name) = name {
|
||||
println!(" {} ({}){}", name, &hex::encode(nid)[..12], label);
|
||||
} else {
|
||||
|
|
@ -697,7 +739,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
|
||||
"create-persona" => {
|
||||
let name = arg.unwrap_or("").to_string();
|
||||
match node.create_posting_identity(name).await {
|
||||
match node.create_posting_identity(name, None).await {
|
||||
Ok(id) => {
|
||||
println!("Created posting identity: {}", hex::encode(id.node_id));
|
||||
}
|
||||
|
|
@ -861,6 +903,166 @@ async fn main() -> anyhow::Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
"register" => {
|
||||
if let Some(rest) = arg {
|
||||
let mut words = rest.split_whitespace();
|
||||
let name = words.next().unwrap_or("").to_string();
|
||||
let keywords: Vec<String> = words.map(|w| w.to_string()).collect();
|
||||
if name.is_empty() {
|
||||
println!("Usage: register <name> [keywords...]");
|
||||
} else {
|
||||
let pid = node.default_posting_id;
|
||||
match node.register_persona(&pid, &name, &keywords).await {
|
||||
Ok(()) => println!(
|
||||
"Registered '{}' ({} keywords). Listed for 30 days; auto-renews while listed.",
|
||||
name, keywords.len()
|
||||
),
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("Usage: register <name> [keywords...]");
|
||||
}
|
||||
}
|
||||
|
||||
"unregister" => {
|
||||
let pid = node.default_posting_id;
|
||||
match node.unregister_persona(&pid).await {
|
||||
Ok(()) => println!("Registry entry removed (signed delete propagated)."),
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
"search" => {
|
||||
let query = arg.unwrap_or("");
|
||||
match node.search_registry(query).await {
|
||||
Ok(hits) => {
|
||||
if hits.is_empty() {
|
||||
println!("(no registry matches)");
|
||||
}
|
||||
for h in hits {
|
||||
println!(
|
||||
" {} {} [{}]",
|
||||
h.name,
|
||||
hex::encode(h.author),
|
||||
h.keywords.join(", "),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
"greet" => {
|
||||
if let Some(rest) = arg {
|
||||
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
|
||||
if parts.len() < 2 {
|
||||
println!("Usage: greet <bio_post_id_hex> <text>");
|
||||
} else {
|
||||
match itsgoin_core::parse_node_id_hex(parts[0]) {
|
||||
Ok(bio_post_id) => {
|
||||
match node.send_greeting(bio_post_id, parts[1].to_string()).await {
|
||||
Ok(()) => println!("Greeting sent (sealed — only the bio author can read it)."),
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Invalid post ID: {}", e),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("Usage: greet <bio_post_id_hex> <text>");
|
||||
}
|
||||
}
|
||||
|
||||
"greetings" => {
|
||||
match node.list_greetings().await {
|
||||
Ok(greetings) => {
|
||||
if greetings.is_empty() {
|
||||
println!("(no greetings)");
|
||||
}
|
||||
for (i, g) in greetings.iter().enumerate() {
|
||||
let name = if g.sender_name.is_empty() {
|
||||
hex::encode(g.sender_persona)[..12].to_string()
|
||||
} else {
|
||||
g.sender_name.clone()
|
||||
};
|
||||
println!(
|
||||
" [{}] {} ({}): {}",
|
||||
i, name, &hex::encode(g.sender_persona)[..12], g.text
|
||||
);
|
||||
}
|
||||
last_greetings = greetings;
|
||||
}
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
"reply" => {
|
||||
if let Some(rest) = arg {
|
||||
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
|
||||
match (parts.first().and_then(|s| s.parse::<usize>().ok()), parts.get(1)) {
|
||||
(Some(idx), Some(text)) => {
|
||||
match last_greetings.get(idx) {
|
||||
Some(g) => {
|
||||
match node.reply_to_greeting(
|
||||
g.comment_author, g.post_id, g.timestamp_ms,
|
||||
text.to_string(),
|
||||
).await {
|
||||
Ok(()) => println!("Reply sent (sealed to the greeting's fresh reply key)."),
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
None => println!("No greeting at index {} — run `greetings` first.", idx),
|
||||
}
|
||||
}
|
||||
_ => println!("Usage: reply <greeting_index> <text>"),
|
||||
}
|
||||
} else {
|
||||
println!("Usage: reply <greeting_index> <text>");
|
||||
}
|
||||
}
|
||||
|
||||
"dismiss" => {
|
||||
match arg.and_then(|a| a.trim().parse::<usize>().ok()) {
|
||||
Some(idx) => match last_greetings.get(idx) {
|
||||
Some(g) => {
|
||||
match node.dismiss_greeting(g.comment_author, g.post_id, g.timestamp_ms).await {
|
||||
Ok(()) => println!("Greeting dismissed (local only)."),
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
None => println!("No greeting at index {} — run `greetings` first.", idx),
|
||||
},
|
||||
None => println!("Usage: dismiss <greeting_index>"),
|
||||
}
|
||||
}
|
||||
|
||||
"greetings-open" => {
|
||||
match arg.map(|a| a.trim()) {
|
||||
Some("on") | Some("off") => {
|
||||
let open = arg.map(|a| a.trim()) == Some("on");
|
||||
let pid = node.default_posting_id;
|
||||
match node.set_greetings_open(&pid, open).await {
|
||||
Ok(()) => println!(
|
||||
"Greetings {} (bio republished).",
|
||||
if open { "OPEN — strangers can send sealed greetings" } else { "CLOSED" }
|
||||
),
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let pid = node.default_posting_id;
|
||||
match node.get_greetings_open(&pid).await {
|
||||
Ok(open) => println!(
|
||||
"Greetings are {}. Usage: greetings-open <on|off>",
|
||||
if open { "open" } else { "closed" }
|
||||
),
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"quit" | "exit" | "q" => {
|
||||
println!("Shutting down...");
|
||||
break;
|
||||
|
|
@ -887,7 +1089,9 @@ async fn print_post(
|
|||
) {
|
||||
let author_hex = hex::encode(post.author);
|
||||
let author_short = &author_hex[..12];
|
||||
let is_me = &post.author == &node.node_id;
|
||||
// Posts are authored by posting identities — "me" = any of our personas.
|
||||
let is_me = node.list_posting_identities().await.unwrap_or_default()
|
||||
.iter().any(|p| p.node_id == post.author);
|
||||
|
||||
let author_name = node.get_display_name(&post.author).await.unwrap_or(None);
|
||||
let author_label = match (author_name, is_me) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue