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:
parent
96118d7ce8
commit
10de3f6108
4 changed files with 144 additions and 4 deletions
|
|
@ -4578,6 +4578,21 @@ impl Node {
|
||||||
post_id: PostId,
|
post_id: PostId,
|
||||||
content: String,
|
content: String,
|
||||||
) -> anyhow::Result<crate::types::InlineComment> {
|
) -> 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
|
self.comment_on_post_inner(post_id, content, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4817,6 +4832,59 @@ impl Node {
|
||||||
Ok(())
|
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 Layer 2: retroactively widen read+comment access on a
|
||||||
/// FoF-gated post the caller authored by sealing a fresh wrap slot
|
/// 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.
|
/// under the given V_x and appending it to the post's gating.
|
||||||
|
|
|
||||||
|
|
@ -1145,6 +1145,53 @@ async fn list_vouches_received(state: State<'_, AppNode>) -> Result<Vec<VouchRec
|
||||||
}).collect())
|
}).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- FoF Layer 2: comment-gated post + commenting ---
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct FoFPostCreatedDto {
|
||||||
|
post_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn create_post_with_fof_comments(
|
||||||
|
state: State<'_, AppNode>,
|
||||||
|
content: String,
|
||||||
|
) -> Result<FoFPostCreatedDto, String> {
|
||||||
|
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]
|
#[tauri::command]
|
||||||
async fn list_follows(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
|
async fn list_follows(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
|
||||||
let node = get_node(&state).await;
|
let node = get_node(&state).await;
|
||||||
|
|
@ -3163,6 +3210,9 @@ pub fn run() {
|
||||||
revoke_vouch_for_peer,
|
revoke_vouch_for_peer,
|
||||||
list_vouches_given,
|
list_vouches_given,
|
||||||
list_vouches_received,
|
list_vouches_received,
|
||||||
|
create_post_with_fof_comments,
|
||||||
|
comment_on_fof_post,
|
||||||
|
revoke_fof_commenter,
|
||||||
list_circles,
|
list_circles,
|
||||||
create_circle,
|
create_circle,
|
||||||
delete_circle,
|
delete_circle,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
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
|
// Convert ArrayBuffers to base64 strings
|
||||||
const files = selectedFiles.map(f => {
|
const files = selectedFiles.map(f => {
|
||||||
const bytes = new Uint8Array(f.data);
|
const bytes = new Uint8Array(f.data);
|
||||||
|
|
@ -2644,9 +2665,9 @@ async function doPost() {
|
||||||
result = await invoke('create_post', params);
|
result = await invoke('create_post', params);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set engagement policy if non-default
|
// Set engagement policy if non-default (FoF posts also publish
|
||||||
const commentPerm = document.getElementById('comment-perm-select').value;
|
// the policy diff so receivers route the comment-receive path
|
||||||
const reactPerm = document.getElementById('react-perm-select').value;
|
// through the FoF four-check verify gate).
|
||||||
if ((commentPerm !== 'public' || reactPerm !== 'both') && result && result.id) {
|
if ((commentPerm !== 'public' || reactPerm !== 'both') && result && result.id) {
|
||||||
try {
|
try {
|
||||||
await invoke('set_comment_policy', {
|
await invoke('set_comment_policy', {
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@
|
||||||
<select id="comment-perm-select" title="Comment permission">
|
<select id="comment-perm-select" title="Comment permission">
|
||||||
<option value="public">Comments: All</option>
|
<option value="public">Comments: All</option>
|
||||||
<option value="followers_only">Comments: Followers</option>
|
<option value="followers_only">Comments: Followers</option>
|
||||||
|
<option value="friends_of_friends">Comments: Friends of Friends</option>
|
||||||
<option value="none">Comments: Off</option>
|
<option value="none">Comments: Off</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="react-perm-select" title="React permission">
|
<select id="react-perm-select" title="React permission">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue