feat(fof-layer2): Tauri commands + frontend compose/comment routing

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) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-05-14 16:07:07 -04:00
parent 96118d7ce8
commit 10de3f6108
4 changed files with 144 additions and 4 deletions

View file

@ -4578,6 +4578,21 @@ impl Node {
post_id: PostId,
content: String,
) -> anyhow::Result<crate::types::InlineComment> {
// 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<crate::types::InlineComment> {
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.