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

@ -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 <node_id>");
}
}
"post-as" => {
if let Some(rest) = arg {
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
if parts.len() < 2 {
println!("Usage: post-as <posting_id_hex> <text>");
} 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 <posting_id_hex> <text>");
}
}
"export-key" => {
match node.export_identity_hex() {
Ok(hex_key) => {