From 7bdb2eb73694a79f72fd299b4d9db334f3a26c07 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Tue, 21 Apr 2026 23:00:21 -0400 Subject: [PATCH] Phase 5 (0.6.4-beta) backend: multi-persona creation + post-as MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now hold multiple posting identities on one device and publish content under any of them. Each persona has its own ed25519 key; peers see them as distinct authors with no link back to the device's network identity. Node methods: - list_posting_identities() -> Vec - create_posting_identity(display_name) — generates a fresh ed25519 key, persists, auto-follows self - delete_posting_identity(node_id) — refuses to delete the default - set_default_posting_identity(node_id) — validates identity exists; Node's cached default_posting_id/secret picks up on next restart - create_post_as(posting_id, content, intent, attachments) — routes through a shared create_post_inner that takes posting_id + posting_secret as parameters Post creation pipeline: - create_post_with_visibility now delegates to create_post_inner using default_posting_id/secret - create_post_inner threads posting_id / posting_secret through every content-signing, encryption, manifest, blob-header, and CDN-manifest step — the persona is fully honored end to end - update_neighbor_manifests now takes a posting_id param too, so posts from persona X only update neighbor manifests for X's own prior posts Tauri IPC: - list_posting_identities / create_posting_identity / delete_posting_identity / set_default_posting_identity - create_post_as with posting_id_hex + the same visibility params as create_post CLI: - personas / create-persona / delete-persona - post-as Smoke-tested two-persona scenario: - A creates "Work" persona; posts from default and Work - B follows both; pulls from A; gets all three posts - Authors are AB84BA... (Work) and 7CD949... (default) — distinct on the wire Frontend UX (Settings > Personas, compose picker, filter pills, merged feed labels) is scoped as a separate commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/cli/src/main.rs | 67 ++++++++++++++++++ crates/core/src/node.rs | 137 ++++++++++++++++++++++++++++++++---- crates/tauri-app/src/lib.rs | 107 ++++++++++++++++++++++++++++ 3 files changed, 296 insertions(+), 15 deletions(-) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 521a6cb..d52655e 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -618,6 +618,73 @@ async fn main() -> anyhow::Result<()> { Err(e) => println!("Error: {}", e), }, + "personas" => match node.list_posting_identities().await { + Ok(identities) => { + let default = { + let s = node.storage.get().await; + s.get_default_posting_id().ok().flatten() + }; + if identities.is_empty() { + println!("(no posting identities)"); + } + for id in identities { + let mark = if Some(id.node_id) == default { " *" } else { "" }; + let name = if id.display_name.is_empty() { "(unnamed)" } else { &id.display_name }; + println!(" {} {} {}{}", &hex::encode(id.node_id)[..12], name, id.created_at, mark); + } + } + Err(e) => println!("Error: {}", e), + }, + + "create-persona" => { + let name = arg.unwrap_or("").to_string(); + match node.create_posting_identity(name).await { + Ok(id) => { + println!("Created posting identity: {}", hex::encode(id.node_id)); + } + Err(e) => println!("Error: {}", e), + } + } + + "delete-persona" => { + if let Some(id_hex) = arg { + match itsgoin_core::parse_node_id_hex(id_hex) { + Ok(nid) => match node.delete_posting_identity(&nid).await { + Ok(()) => println!("Deleted posting identity"), + Err(e) => println!("Error: {}", e), + }, + Err(e) => println!("Invalid node id: {}", e), + } + } else { + println!("Usage: delete-persona "); + } + } + + "post-as" => { + if let Some(rest) = arg { + let parts: Vec<&str> = rest.splitn(2, ' ').collect(); + if parts.len() < 2 { + println!("Usage: post-as "); + } else { + match itsgoin_core::parse_node_id_hex(parts[0]) { + Ok(nid) => { + match node + .create_post_as(&nid, parts[1].to_string(), + itsgoin_core::types::VisibilityIntent::Public, vec![]) + .await + { + Ok((id, _, _)) => println!("Posted as persona! ID: {}", hex::encode(id)), + Err(e) => println!("Error: {}", e), + } + } + Err(e) => println!("Invalid node id: {}", e), + } + } + } else { + println!("Usage: post-as "); + } + } + "export-key" => { match node.export_identity_hex() { Ok(hex_key) => { diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index c3fa57b..c0ce541 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -577,6 +577,66 @@ impl Node { self.delivery_budget_remaining.load(AtomicOrdering::Relaxed) } + // ---- Posting identities (multi-persona) ---- + + /// List all posting identities held by this device. + pub async fn list_posting_identities(&self) -> anyhow::Result> { + let s = self.storage.get().await; + s.list_posting_identities() + } + + /// Create a new posting identity with a fresh ed25519 key. Auto-follows + /// the new identity so its own posts show in the merged feed. + pub async fn create_posting_identity( + &self, + display_name: String, + ) -> anyhow::Result { + let key = iroh::SecretKey::generate(&mut rand::rng()); + let seed: [u8; 32] = key.to_bytes(); + let node_id: NodeId = *key.public().as_bytes(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let identity = crate::types::PostingIdentity { + node_id, + secret_seed: seed, + display_name, + 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)?; + Ok(identity) + } + + /// 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] { @@ -638,6 +698,47 @@ impl Node { content: String, intent: VisibilityIntent, attachment_data: Vec<(Vec, String)>, + ) -> anyhow::Result<(PostId, Post, PostVisibility)> { + self.create_post_inner( + &self.default_posting_id, + &self.default_posting_secret, + content, + intent, + attachment_data, + ).await + } + + /// Create a post authored by a specific posting identity held by this + /// device. Looks up the posting secret and routes through the same post + /// creation pipeline as the default. + pub async fn create_post_as( + &self, + posting_id: &NodeId, + content: String, + intent: VisibilityIntent, + attachment_data: Vec<(Vec, String)>, + ) -> anyhow::Result<(PostId, Post, PostVisibility)> { + let identity = { + let s = self.storage.get().await; + s.get_posting_identity(posting_id)? + .ok_or_else(|| anyhow::anyhow!("unknown posting identity"))? + }; + self.create_post_inner( + &identity.node_id, + &identity.secret_seed, + content, + intent, + attachment_data, + ).await + } + + async fn create_post_inner( + &self, + posting_id: &NodeId, + posting_secret: &[u8; 32], + content: String, + intent: VisibilityIntent, + attachment_data: Vec<(Vec, String)>, ) -> anyhow::Result<(PostId, Post, PostVisibility)> { // Validate attachments if attachment_data.len() > 4 { @@ -726,7 +827,7 @@ impl Node { EncryptionMode::Public => (content, PostVisibility::Public), EncryptionMode::Recipient { cek, recipients } => { let (encrypted, wrapped_keys) = - crypto::encrypt_post_with_cek(&content, &cek, &self.default_posting_secret, &self.node_id, &recipients)?; + crypto::encrypt_post_with_cek(&content, &cek, posting_secret, posting_id, &recipients)?; ( encrypted, PostVisibility::Encrypted { @@ -749,7 +850,7 @@ impl Node { }; let post = Post { - author: self.default_posting_id, + author: *posting_id, content: final_content, attachments, timestamp_ms: now, @@ -761,7 +862,7 @@ impl Node { 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, &self.node_id, att.size_bytes, &att.mime_type, now)?; + 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); } @@ -795,7 +896,7 @@ impl Node { let blob_header = crate::types::BlobHeader { post_id, - author: self.default_posting_id, + author: *posting_id, reactions: vec![], comments: vec![], policy: Default::default(), @@ -806,19 +907,19 @@ impl Node { prior_author: None, }; let header_json = serde_json::to_string(&blob_header)?; - storage.store_blob_header(&post_id, &self.node_id, &header_json, now)?; + 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(&self.node_id, now, 10)?; + let (previous, _following) = storage.get_author_post_neighborhood(posting_id, now, 10)?; drop(storage); let manifest = crate::types::AuthorManifest { post_id, - author: self.default_posting_id, + author: *posting_id, author_addresses: self.network.our_addresses(), created_at: now, updated_at: now, @@ -826,7 +927,7 @@ impl Node { following_posts: vec![], signature: vec![], }; - let sig = crypto::sign_manifest(&self.default_posting_secret, &manifest); + let sig = crypto::sign_manifest(posting_secret, &manifest); let mut manifest = manifest; manifest.signature = sig; @@ -834,17 +935,17 @@ impl Node { { let storage = self.storage.get().await; for att in &post.attachments { - storage.store_cdn_manifest(&att.cid, &manifest_json, &self.node_id, now)?; + 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(&post_id, now).await; + 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(&self.node_id).unwrap_or_default() + storage.get_manifests_for_author_blobs(posting_id).unwrap_or_default() }; let our_addrs = self.network.our_addresses(); for (push_cid, push_json) in &manifests_to_push { @@ -873,9 +974,15 @@ impl Node { /// 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(&self, new_post_id: &PostId, new_timestamp_ms: u64) { + 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(&self.node_id) { + 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); @@ -909,14 +1016,14 @@ impl Node { } manifest.following_posts.push(new_entry.clone()); manifest.updated_at = new_timestamp_ms; - manifest.signature = crypto::sign_manifest(&self.default_posting_secret, &manifest); + 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, &self.node_id, new_timestamp_ms); + let _ = storage.store_cdn_manifest(&cid, &updated_json, posting_id, new_timestamp_ms); drop(storage); } } diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 7211c90..26a3828 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -475,6 +475,108 @@ async fn create_post_with_files( Ok(post_to_dto(&id, &post, &vis, decrypted.as_deref(), &node).await) } +/// Posting identity DTO for IPC. +#[derive(serde::Serialize)] +struct PostingIdentityDto { + #[serde(rename = "nodeId")] + node_id: String, + #[serde(rename = "displayName")] + display_name: String, + #[serde(rename = "createdAt")] + created_at: u64, + #[serde(rename = "isDefault")] + is_default: bool, +} + +#[tauri::command] +async fn list_posting_identities( + state: State<'_, AppNode>, +) -> Result, String> { + let node = get_node(&state).await; + let identities = node.list_posting_identities().await.map_err(|e| e.to_string())?; + let default = { + let s = node.storage.get().await; + s.get_default_posting_id().ok().flatten() + }; + Ok(identities + .into_iter() + .map(|id| PostingIdentityDto { + node_id: hex::encode(id.node_id), + display_name: id.display_name, + created_at: id.created_at, + is_default: Some(id.node_id) == default, + }) + .collect()) +} + +#[tauri::command] +async fn create_posting_identity( + state: State<'_, AppNode>, + display_name: String, +) -> Result { + let node = get_node(&state).await; + let id = node.create_posting_identity(display_name).await.map_err(|e| e.to_string())?; + Ok(PostingIdentityDto { + node_id: hex::encode(id.node_id), + display_name: id.display_name, + created_at: id.created_at, + is_default: false, + }) +} + +#[tauri::command] +async fn delete_posting_identity( + state: State<'_, AppNode>, + node_id_hex: String, +) -> Result<(), String> { + let node = get_node(&state).await; + let nid = itsgoin_core::parse_node_id_hex(&node_id_hex).map_err(|e| e.to_string())?; + node.delete_posting_identity(&nid).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn set_default_posting_identity( + state: State<'_, AppNode>, + node_id_hex: String, +) -> Result<(), String> { + let node = get_node(&state).await; + let nid = itsgoin_core::parse_node_id_hex(&node_id_hex).map_err(|e| e.to_string())?; + node.set_default_posting_identity(&nid).await.map_err(|e| e.to_string()) +} + +/// Create a post signed by a specific posting identity instead of the default. +#[tauri::command] +async fn create_post_as( + state: State<'_, AppNode>, + posting_id_hex: String, + content: String, + visibility: Option, + circle_name: Option, + recipient_hex: Option, +) -> Result { + let node = get_node(&state).await; + let posting_id = itsgoin_core::parse_node_id_hex(&posting_id_hex).map_err(|e| e.to_string())?; + let intent = match visibility.as_deref() { + Some("friends") => VisibilityIntent::Friends, + Some("circle") => { + let name = circle_name.ok_or("circle_name required for circle visibility")?; + VisibilityIntent::Circle(name) + } + Some("direct") => { + let hex = recipient_hex.ok_or("recipient_hex required for direct visibility")?; + let nid = itsgoin_core::parse_node_id_hex(&hex).map_err(|e| e.to_string())?; + VisibilityIntent::Direct(vec![nid]) + } + _ => VisibilityIntent::Public, + }; + let (id, post, vis) = node + .create_post_as(&posting_id, content, intent, vec![]) + .await + .map_err(|e| e.to_string())?; + let decrypted = decrypt_just_created(&node, &post, &vis).await; + Ok(post_to_dto(&id, &post, &vis, decrypted.as_deref(), &node).await) +} + /// Return the filesystem path of a blob if it exists locally (for streaming video/media). /// For non-public (encrypted) posts, returns None since raw encrypted blobs can't be served /// via the asset protocol — the frontend must use IPC-based get_blob instead. @@ -2760,6 +2862,11 @@ pub fn run() { set_profile, create_post, create_post_with_files, + create_post_as, + list_posting_identities, + create_posting_identity, + delete_posting_identity, + set_default_posting_identity, get_blob, get_blob_path, save_and_open_blob,