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

@ -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<Vec<PostingIdentityDto>, 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<PostingIdentityDto, String> {
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<String>,
circle_name: Option<String>,
recipient_hex: Option<String>,
) -> Result<PostDto, String> {
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,