From e74bd4e6c6998e95d13a7bd4732813c9fcc529b1 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 23 Apr 2026 08:47:30 -0400 Subject: [PATCH] Profile-post backfill + prune disposable first-run persona on import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs the v0.6.2 Discover story was going to expose: 1) Many named personas don't have a profile post yet, so the planned profile-post-driven Discover listing would show them as headless. Existing personas predate the Phase 2d profile-post primitive, and imported personas arrive with a display_name but no matching post. 2) Fresh install always generates a blank disposable persona before the user can pick fresh-vs-import. If the user picks import, that blank persona lingers forever — visible in the Personas list, a potential default, confusing. Fixes: Profile-post backfill (node.rs): - New `Node::backfill_profile_posts_for_named_personas`: scans posting identities, skips unnamed or already-covered ones, and emits a signed profile post at the persona's own `created_at` timestamp. Run once from `Node::open_with_bind` after migrations. Idempotent via the new `Storage::has_profile_post_by_author` check. Chronology is preserved (post timestamp matches persona creation), so a later genuine profile update the user authors always wins the `> old_ts` monotonicity check on receivers. - `Node::create_posting_identity(name)`: if `name` is non-empty, emits the profile post inline so new named personas are Discover-able the moment they're created. Uses the new `publish_profile_post_as` helper, which signs with the persona's own secret (not the default posting secret) and propagates via `update_neighbor_manifests_as`. First-run persona marker + targeted prune (node.rs + storage.rs + import): - `Node::open_with_bind`'s auto-gen block now also writes `first_run_auto_persona_id = ` into the settings kv. This marks the specific disposable persona from the fresh-install flow; later prune logic uses this id, not a "any empty persona" rule, so manual empty personas are never touched. - `Node::try_prune_first_run_auto_persona`: deletes the marked id iff all four gates pass — still exists, no display_name, no authored posts, no authored reactions/comments, and it's no longer the current default. Any one failure → clear the marker and keep the persona. New storage helpers `has_any_post_by_author`, `has_any_engagement_by_author` back the check. - `set_profile` clears the marker when a non-empty name lands on the marked persona (user claimed it). - `storage.delete_setting(key)` — new one-line helper. - `import_as_personas_cmd` in tauri-app calls the prune after import completes; the cmd's return message reports "(cleared blank starter persona)" when it fires. - New `get_first_run_auto_persona_id` Tauri command so the frontend can filter the blank persona out of the Personas list while it still exists. - Frontend `loadPersonas` filters the marker id out of `personasCache` before rendering. Tests: 124 / 124 core tests pass. --- crates/core/src/node.rs | 229 +++++++++++++++++++++++++++++++++++- crates/core/src/storage.rs | 48 ++++++++ crates/tauri-app/src/lib.rs | 27 ++++- frontend/app.js | 10 ++ 4 files changed, 308 insertions(+), 6 deletions(-) diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 18875ad..b9fc10b 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -133,6 +133,11 @@ impl Node { created_at: now, })?; s.set_default_posting_id(&nid)?; + // Mark this as the disposable auto-gen persona from the + // fresh-install flow. If the user subsequently imports, we + // prune this id iff it's still pristine (no name, no posts, + // no engagement). See `try_prune_first_run_auto_persona`. + let _ = s.set_setting("first_run_auto_persona_id", &hex::encode(nid)); info!(posting_id = %hex::encode(nid), "Generated initial posting identity (independent of network key)"); } } @@ -223,6 +228,14 @@ impl Node { budget_last_reset_ms, }; + // Startup backfill: any named persona without a profile post gets + // one synthesized at its own `created_at`. Makes legacy / imported + // named personas Discover-able without requiring a manual rename. + // Swallow errors — backfill is best-effort; no reason to block init. + if let Err(e) = node.backfill_profile_posts_for_named_personas().await { + warn!(error = %e, "Profile-post backfill failed; continuing init"); + } + Ok(node) } @@ -649,16 +662,213 @@ impl Node { let identity = crate::types::PostingIdentity { node_id, secret_seed: seed, - display_name, + display_name: display_name.clone(), 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)?; + { + 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)?; + } + + // If the user supplied a non-empty display name at creation time, + // emit a signed profile post immediately. This makes the persona + // Discover-able by other nodes even before the user posts anything + // under it. `publish_profile_post_as` signs with the persona's own + // secret (not the default posting secret) and propagates via the + // normal neighbor-manifest CDN path. + if !display_name.is_empty() { + if let Err(e) = self.publish_profile_post_as(&node_id, &seed, &display_name, "", None).await { + warn!(persona = hex::encode(node_id), error = %e, "Failed to emit initial profile post for new persona"); + } + } + Ok(identity) } + /// Build + store + propagate a `VisibilityIntent::Profile` post authored + /// by the given persona (not the default posting identity). Extracted so + /// both `create_posting_identity` and the startup backfill can use it. + async fn publish_profile_post_as( + &self, + posting_id: &NodeId, + posting_secret: &[u8; 32], + display_name: &str, + bio: &str, + avatar_cid: Option<[u8; 32]>, + ) -> anyhow::Result<()> { + let profile_post = crate::profile::build_profile_post( + posting_id, + posting_secret, + display_name, + bio, + avatar_cid, + ); + let profile_post_id = crate::content::compute_post_id(&profile_post); + let timestamp_ms = profile_post.timestamp_ms; + + { + let storage = self.storage.get().await; + storage.store_post_with_intent( + &profile_post_id, + &profile_post, + &PostVisibility::Public, + &VisibilityIntent::Profile, + )?; + crate::profile::apply_profile_post_if_applicable( + &*storage, + &profile_post, + Some(&VisibilityIntent::Profile), + )?; + } + self.update_neighbor_manifests_as( + posting_id, + posting_secret, + &profile_post_id, + timestamp_ms, + ).await; + Ok(()) + } + + /// Backfill: for every posting identity with a non-empty display_name + /// that doesn't already have a `VisibilityIntent::Profile` post, + /// synthesize one so the persona becomes Discover-able. Uses the + /// persona's `created_at` as the post timestamp so chronology matches + /// the persona's history. + /// + /// Called once from `Node::open_with_bind` after all migrations. Safe to + /// re-run: the `has_profile_post_by_author` check makes it idempotent. + async fn backfill_profile_posts_for_named_personas(&self) -> anyhow::Result { + let personas = { + let storage = self.storage.get().await; + storage.list_posting_identities()? + }; + let mut backfilled = 0usize; + for pi in personas { + if pi.display_name.is_empty() { + continue; + } + { + let storage = self.storage.get().await; + if storage.has_profile_post_by_author(&pi.node_id)? { + continue; + } + } + // Build a profile post whose internal timestamp equals the + // persona's created_at. This stops the backfilled post from + // later losing a monotonicity check against a real profile + // update the user authors in the future. + let signature = crate::crypto::sign_profile( + &pi.secret_seed, + &pi.display_name, + "", + &None, + pi.created_at, + ); + let content = crate::types::ProfilePostContent { + display_name: pi.display_name.clone(), + bio: String::new(), + avatar_cid: None, + timestamp_ms: pi.created_at, + signature, + }; + let post = Post { + author: pi.node_id, + content: serde_json::to_string(&content).unwrap_or_default(), + attachments: vec![], + timestamp_ms: pi.created_at, + }; + let post_id = crate::content::compute_post_id(&post); + { + let storage = self.storage.get().await; + storage.store_post_with_intent( + &post_id, + &post, + &PostVisibility::Public, + &VisibilityIntent::Profile, + )?; + crate::profile::apply_profile_post_if_applicable( + &*storage, + &post, + Some(&VisibilityIntent::Profile), + )?; + } + self.update_neighbor_manifests_as( + &pi.node_id, + &pi.secret_seed, + &post_id, + pi.created_at, + ).await; + backfilled += 1; + } + if backfilled > 0 { + info!(count = backfilled, "Backfilled profile posts for named personas without one"); + } + Ok(backfilled) + } + + /// If the fresh-install auto-gen persona is still pristine (no name, no + /// posts, no engagement, not the current default), delete it. Called at + /// the end of `import_as_personas` so an "import as persona" flow + /// doesn't leave an orphan blank persona around. + /// + /// Any of four sticky conditions prevents deletion: + /// - the user set a display_name + /// - the user authored a post under this persona + /// - the user authored a reaction or comment under this persona + /// - this persona is still the current default (no imported identity + /// replaced it) + pub async fn try_prune_first_run_auto_persona(&self) -> anyhow::Result { + let (marker_hex, current_default) = { + let s = self.storage.get().await; + let m = s.get_setting("first_run_auto_persona_id")?; + let d = s.get_default_posting_id()?; + (m, d) + }; + let Some(hex_str) = marker_hex else { return Ok(false); }; + let Ok(marker_id) = crate::parse_node_id_hex(&hex_str) else { + // Corrupt marker — clear and move on. + let s = self.storage.get().await; + let _ = s.delete_setting("first_run_auto_persona_id"); + return Ok(false); + }; + + let storage = self.storage.get().await; + + // Still the default? Import didn't replace it — keep. + if current_default == Some(marker_id) { + let _ = storage.delete_setting("first_run_auto_persona_id"); + return Ok(false); + } + // Persona still exists? + let Some(pi) = storage.get_posting_identity(&marker_id)? else { + let _ = storage.delete_setting("first_run_auto_persona_id"); + return Ok(false); + }; + // User named it? Keep. + if !pi.display_name.is_empty() { + let _ = storage.delete_setting("first_run_auto_persona_id"); + return Ok(false); + } + // User authored anything under it? Keep. + if storage.has_any_post_by_author(&marker_id)? { + let _ = storage.delete_setting("first_run_auto_persona_id"); + return Ok(false); + } + if storage.has_any_engagement_by_author(&marker_id)? { + let _ = storage.delete_setting("first_run_auto_persona_id"); + return Ok(false); + } + // All gates passed — persona is definitively pristine and no longer + // the default. Safe to drop. + storage.delete_posting_identity(&marker_id)?; + let _ = storage.remove_follow(&marker_id); + let _ = storage.delete_setting("first_run_auto_persona_id"); + info!(persona = %hex_str, "Pruned pristine fresh-install persona after import"); + Ok(true) + } + /// 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<()> { @@ -1263,6 +1473,8 @@ impl Node { let timestamp_ms = profile_post.timestamp_ms; // Store post with VisibilityIntent::Profile + apply (upserts profile row). + // If naming the fresh-install auto-gen persona with a non-empty name, + // clear the disposability marker — user has claimed this persona. { let storage = self.storage.get().await; storage.store_post_with_intent( @@ -1276,6 +1488,13 @@ impl Node { &profile_post, Some(&VisibilityIntent::Profile), )?; + if !display_name.is_empty() { + if let Ok(Some(marker)) = storage.get_setting("first_run_auto_persona_id") { + if marker == hex::encode(posting_id) { + let _ = storage.delete_setting("first_run_auto_persona_id"); + } + } + } } // Propagate via neighbor-manifest header diffs like any other post. diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index fe4041e..074e2dd 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -1677,6 +1677,11 @@ impl Storage { Ok(()) } + pub fn delete_setting(&self, key: &str) -> anyhow::Result<()> { + self.conn.execute("DELETE FROM settings WHERE key = ?1", params![key])?; + Ok(()) + } + // --- Seen engagement tracking --- /// Get the seen engagement counts for a post (react_count, comment_count). @@ -2662,6 +2667,49 @@ impl Storage { } /// Find posts authored by us that were intended for a specific circle. + /// True if the given author has at least one `VisibilityIntent::Profile` + /// post stored. Used by the startup backfill to avoid re-emitting a + /// profile post for personas that already have one. + pub fn has_profile_post_by_author(&self, author: &NodeId) -> anyhow::Result { + let count: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM posts + WHERE author = ?1 AND visibility_intent = '\"Profile\"' + LIMIT 1", + params![author.as_slice()], + |row| row.get(0), + ).unwrap_or(0); + Ok(count > 0) + } + + /// True if `author` has any row in the posts table (any intent / visibility). + /// Used to decide whether the fresh-install auto-gen persona has been + /// "used" and should therefore NOT be pruned on import. + pub fn has_any_post_by_author(&self, author: &NodeId) -> anyhow::Result { + let count: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM posts WHERE author = ?1 LIMIT 1", + params![author.as_slice()], + |row| row.get(0), + ).unwrap_or(0); + Ok(count > 0) + } + + /// True if `author` has authored a reaction or a comment. Same purpose as + /// `has_any_post_by_author` but for the engagement tables. + pub fn has_any_engagement_by_author(&self, author: &NodeId) -> anyhow::Result { + let reacts: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM reactions WHERE reactor = ?1 LIMIT 1", + params![author.as_slice()], + |row| row.get(0), + ).unwrap_or(0); + if reacts > 0 { return Ok(true); } + let comments: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM comments WHERE author = ?1 LIMIT 1", + params![author.as_slice()], + |row| row.get(0), + ).unwrap_or(0); + Ok(comments > 0) + } + pub fn find_posts_by_circle_intent( &self, circle_name: &str, diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 87e94ba..4589928 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -1416,6 +1416,20 @@ async fn get_update_channel(state: State<'_, AppNode>) -> Result .unwrap_or_else(|| "stable".to_string())) } +/// Return the hex id of the disposable fresh-install persona (if it still +/// exists and hasn't been claimed). Frontend uses this to filter the blank +/// starter out of the Personas list so the user never sees a "ghost" +/// persona waiting between install and their first-run choice. +/// Returns an empty string when there's no disposable persona to hide. +#[tauri::command] +async fn get_first_run_auto_persona_id(state: State<'_, AppNode>) -> Result { + let node = get_node(&state).await; + let storage = node.storage.get().await; + Ok(storage.get_setting("first_run_auto_persona_id") + .map_err(|e| e.to_string())? + .unwrap_or_default()) +} + /// Persist the user's preferred update channel. #[tauri::command] async fn set_update_channel(state: State<'_, AppNode>, channel: String) -> Result<(), String> { @@ -2804,7 +2818,17 @@ async fn import_as_personas_cmd( &node.storage, &node.blob_store, ).await.map_err(|e| e.to_string())?; - Ok(result.message) + // Drop the disposable fresh-install persona if it's still pristine. + // Safe-by-construction: the helper bails unless ALL of [no name, no + // posts, no engagement, no longer the default] hold. + let pruned = node.try_prune_first_run_auto_persona().await + .unwrap_or(false); + let msg = if pruned { + format!("{} (cleared blank starter persona)", result.message) + } else { + result.message + }; + Ok(msg) } #[tauri::command] @@ -3023,6 +3047,7 @@ pub fn run() { get_update_channel, set_update_channel, open_url_external, + get_first_run_auto_persona_id, list_connections, worm_lookup, list_social_routes, diff --git a/frontend/app.js b/frontend/app.js index 975556b..bcf175a 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -3521,6 +3521,16 @@ async function loadPersonas() { personasCache = []; console.error('list_posting_identities:', e); } + // Filter out the fresh-install disposable persona. It's auto-created + // before the user has picked fresh-vs-import, and will be pruned on + // import if still pristine. Hiding it from the Personas UI stops the + // user from seeing a ghost "blank" persona during the first-run flow. + try { + const hiddenId = await invoke('get_first_run_auto_persona_id'); + if (hiddenId) { + personasCache = personasCache.filter(p => p.nodeId !== hiddenId); + } + } catch (_) {} renderPersonasList(); renderComposePersonaPicker(); renderFeedPersonaFilter();