From 10de3f61081f03036aefca70966728c9f822d635 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 16:07:07 -0400 Subject: [PATCH] feat(fof-layer2): Tauri commands + frontend compose/comment routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new Tauri commands surface Layer 2 to the frontend: - create_post_with_fof_comments(content) → { postId } Builds the FoF gating block from the default persona's keyring and publishes a Mode 2 post (public body, FoF-gated comments). - comment_on_fof_post(post_id_hex, body) Backwards-compat wrapper: comment_on_post now auto-detects FoF gating on the parent post and dispatches internally. Existing comment-send UI works unchanged. - revoke_fof_commenter(post_id_hex, pub_x_index, reason_code) Wraps the Node helper. UI for per-comment revocation can hang off this without further Node changes. Node::comment_on_post now branches on parent post fof_gating presence — this is the central routing point so neither the frontend nor existing comment-send code needs to know about FoF specifics. FoF posts get FoF comments automatically; non-FoF posts get the legacy path. Same Tauri command, same frontend handler. Frontend: - Compose: comment-perm-select gains a "Friends of Friends" option. When picked, compose dispatches to create_post_with_fof_comments instead of the standard create_post. Standard set_comment_policy diff fires after so receivers' four-check accept rule activates. - Attachments + non-default-persona FoF posts are out-of-v1; compose reports + aborts rather than silently producing a non-FoF post. Layer 2 backend + minimal-viable UI complete. Granular per-comment revoke UI and access-grant UI deferred — Node + Tauri primitives exist; the surfaces can be added without further crypto work. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/node.rs | 68 +++++++++++++++++++++++++++++++++++++ crates/tauri-app/src/lib.rs | 50 +++++++++++++++++++++++++++ frontend/app.js | 29 +++++++++++++--- frontend/index.html | 1 + 4 files changed, 144 insertions(+), 4 deletions(-) diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 0ab4d1a..76ea428 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -4578,6 +4578,21 @@ impl Node { post_id: PostId, content: String, ) -> anyhow::Result { + // FoF Layer 2: if the post carries fof_gating, route through + // the FoF comment path so the comment is encrypted under + // CEK_comments + signed under priv_x. The CDN four-check accept + // rule on receivers will then validate the comment. + let is_fof_gated = { + let storage = self.storage.get().await; + storage.get_post(&post_id) + .ok() + .flatten() + .and_then(|p| p.fof_gating) + .is_some() + }; + if is_fof_gated { + return self.comment_on_fof_post(post_id, content).await; + } self.comment_on_post_inner(post_id, content, None).await } @@ -4817,6 +4832,59 @@ impl Node { Ok(()) } + /// FoF Layer 2: author a comment on a FoF-gated post. Finds the + /// caller's unlock (any held V_x that matches one of the post's + /// slots), encrypts the body under CEK_comments, signs with the + /// per-V_x priv_x, attaches pub_x_index, stores locally, and + /// propagates via the standard engagement-diff path. + /// + /// Returns the constructed InlineComment. Errors if the post + /// isn't FoF-gated, or if no held V_x admits the caller. + pub async fn comment_on_fof_post( + &self, + post_id: PostId, + body: String, + ) -> anyhow::Result { + let (unlock, slot_binder_nonce, commenter_id, commenter_secret, post_author) = { + let storage = self.storage.get().await; + let post = storage.get_post(&post_id)? + .ok_or_else(|| anyhow::anyhow!("post not found"))?; + let gating = post.fof_gating.as_ref() + .ok_or_else(|| anyhow::anyhow!("post is not FoF-gated"))?; + let slot_binder_nonce = gating.slot_binder_nonce; + let unlock = crate::fof::find_unlock_for_post(&*storage, &post)? + .ok_or_else(|| anyhow::anyhow!("no held V_x unlocks this post — not in FoF set"))?; + let identity = storage.get_posting_identity(&unlock.persona_id)? + .ok_or_else(|| anyhow::anyhow!("unlocking persona not on device"))?; + (unlock, slot_binder_nonce, identity.node_id, identity.secret_seed, post.author) + }; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let comment = crate::fof::build_fof_comment( + &post_id, &unlock, &slot_binder_nonce, + &commenter_id, &commenter_secret, &body, None, now, + )?; + + // Store locally. + { + let storage = self.storage.get().await; + storage.store_comment(&comment)?; + } + + // Propagate via engagement-diff path. + let diff = crate::protocol::BlobHeaderDiffPayload { + post_id, + author: post_author, + ops: vec![crate::types::BlobHeaderDiffOp::AddComment(comment.clone())], + timestamp_ms: now, + }; + self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await; + + Ok(comment) + } + /// FoF Layer 2: retroactively widen read+comment access on a /// FoF-gated post the caller authored by sealing a fresh wrap slot /// under the given V_x and appending it to the post's gating. diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 2b99819..96378ee 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -1145,6 +1145,53 @@ async fn list_vouches_received(state: State<'_, AppNode>) -> Result, + content: String, +) -> Result { + let node = get_node(&state).await; + let (post_id, _post, _vis, _cek) = node + .create_post_with_fof_comments(content, vec![]) + .await + .map_err(|e| e.to_string())?; + Ok(FoFPostCreatedDto { post_id: hex::encode(post_id) }) +} + +#[tauri::command] +async fn comment_on_fof_post( + state: State<'_, AppNode>, + post_id_hex: String, + body: String, +) -> Result<(), String> { + let node = get_node(&state).await; + let pid = parse_node_id(&post_id_hex)?; // PostId is also [u8; 32] + node.comment_on_fof_post(pid, body).await + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn revoke_fof_commenter( + state: State<'_, AppNode>, + post_id_hex: String, + pub_x_index: u32, + reason_code: u8, +) -> Result<(), String> { + let node = get_node(&state).await; + let pid = parse_node_id(&post_id_hex)?; + node.revoke_fof_commenter(pid, pub_x_index, reason_code).await + .map_err(|e| e.to_string()) +} + #[tauri::command] async fn list_follows(state: State<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; @@ -3163,6 +3210,9 @@ pub fn run() { revoke_vouch_for_peer, list_vouches_given, list_vouches_received, + create_post_with_fof_comments, + comment_on_fof_post, + revoke_fof_commenter, list_circles, create_circle, delete_circle, diff --git a/frontend/app.js b/frontend/app.js index 2050913..8abd5e6 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -2629,8 +2629,29 @@ async function doPost() { } } + const commentPerm = document.getElementById('comment-perm-select').value; + const reactPerm = document.getElementById('react-perm-select').value; + let result; - if (selectedFiles.length > 0) { + if (commentPerm === 'friends_of_friends') { + // FoF Layer 2: body is still public (Mode 2) but the post + // carries a fof_gating block built from the author's + // keyring. Routed through a dedicated command because the + // gating block is signed at publish time (can't be added + // via SetPolicy after the fact). + // Attachments + non-default-persona FoF posts are not yet + // supported by the v1 command — fall through to the + // standard path with a warning if either applies. + if (selectedFiles.length > 0 || params.postingIdHex) { + toast('FoF posts with attachments or non-default persona require Mode 1 (later).'); + postBtn.disabled = false; + return; + } + const created = await invoke('create_post_with_fof_comments', { + content: params.content, + }); + result = { id: created.postId }; + } else if (selectedFiles.length > 0) { // Convert ArrayBuffers to base64 strings const files = selectedFiles.map(f => { const bytes = new Uint8Array(f.data); @@ -2644,9 +2665,9 @@ async function doPost() { result = await invoke('create_post', params); } - // Set engagement policy if non-default - const commentPerm = document.getElementById('comment-perm-select').value; - const reactPerm = document.getElementById('react-perm-select').value; + // Set engagement policy if non-default (FoF posts also publish + // the policy diff so receivers route the comment-receive path + // through the FoF four-check verify gate). if ((commentPerm !== 'public' || reactPerm !== 'both') && result && result.id) { try { await invoke('set_comment_policy', { diff --git a/frontend/index.html b/frontend/index.html index 577fe08..0f62760 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -104,6 +104,7 @@