Phase 5 (0.6.4-beta) backend: multi-persona creation + post-as

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<PostingIdentity>
- 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 <name> / delete-persona <id>
- post-as <posting_id> <text>

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) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-04-21 23:00:21 -04:00
parent ce4b989b17
commit 7bdb2eb736
3 changed files with 296 additions and 15 deletions

View file

@ -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<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.
pub async fn create_posting_identity(
&self,
display_name: String,
) -> 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,
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<u8>, 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<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,
).await
}
async fn create_post_inner(
&self,
posting_id: &NodeId,
posting_secret: &[u8; 32],
content: String,
intent: VisibilityIntent,
attachment_data: Vec<(Vec<u8>, 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);
}
}