feat(fof-layer3): Mode 1 publish + read + Tauri + UI wiring

End-to-end FoFClosed (Mode 1: encrypted body + FoF comments):

Node API:
- create_post_fof_closed(content) -> (PostId, Post, cek)
  Builds gating, encrypts body via fof::encrypt_fof_body, base64s it
  into post.content, stores with visibility=FoFClosed +
  intent=Public, propagates via update_neighbor_manifests_as.
- read_fof_closed_body(post_id) -> Option<String>
  Trial-unlocks via find_unlock_for_post, decrypts body, returns
  plaintext. Returns None for non-FoFClosed or non-member readers.

Tauri commands:
- create_post_fof_closed, read_fof_closed_body. Registered in
  generate_handler!.

Feed rendering:
- PostDto.visibility carries the new "fof-closed" string.
- renderPost(): FoFClosed posts render with a locked placeholder
  (data-fof-closed-pending=post_id span). Visual badge added.
- unlockFoFClosedPlaceholders(rootEl): post-render async pass that
  scans for placeholder spans and dispatches read_fof_closed_body
  for each. Fills in body for FoF readers; falls back to a
  "not in this FoF set" notice otherwise.
- Wired into feed-list and my-posts-list render paths.

Compose:
- "Body+Comments: FoF only (Mode 1)" option in comment-perm-select.
  Selected → dispatches to create_post_fof_closed.

CLI feed renderer + Tauri feed-DTO match arms updated to handle
FoFClosed.

New end-to-end test brings total to 146:
- fof_closed_body_end_to_end: Alice authors FoFClosed body; Bob (with
  Alice's V_me in his keyring) unlocks + decrypts; Carol (no
  matching V_x) cannot unlock and sees only ciphertext.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-05-14 15:19:42 -06:00
parent 856f386231
commit 66b78041fc
6 changed files with 255 additions and 4 deletions

View file

@ -256,6 +256,10 @@ async fn post_to_dto(
Some(text) => ("encrypted-for-me".to_string(), Some(text.to_string())),
None => ("encrypted".to_string(), None),
},
// FoF Layer 3: FoFClosed body. Decrypted is None from the sync
// feed-pre-decrypt helper; the frontend calls read_fof_closed_body
// for any post with visibility == "fof-closed" to fill in the body.
PostVisibility::FoFClosed => ("fof-closed".to_string(), None),
};
let recipients = match vis {
PostVisibility::Encrypted { recipients } => {
@ -346,6 +350,10 @@ async fn decrypt_just_created(
None
}
}
// FoF Layer 3: FoFClosed body decrypt happens via the dedicated
// async read_fof_closed_body command. This sync helper returns
// None and the frontend dispatches the FoF read explicitly.
PostVisibility::FoFClosed => None,
}
}
@ -910,6 +918,7 @@ async fn post_to_dto_batch(
Some(text) => ("encrypted-for-me".to_string(), Some(text.clone())),
None => ("encrypted".to_string(), None),
},
PostVisibility::FoFClosed => ("fof-closed".to_string(), None),
};
let recipients = match vis {
PostVisibility::Encrypted { recipients } => {
@ -1192,6 +1201,33 @@ async fn revoke_fof_commenter(
.map_err(|e| e.to_string())
}
// FoF Layer 3: Mode 1 (FoFClosed) — encrypted body + FoF comments.
#[tauri::command]
async fn create_post_fof_closed(
state: State<'_, AppNode>,
content: String,
) -> Result<FoFPostCreatedDto, String> {
let node = get_node(&state).await;
let (post_id, _post, _cek) = node
.create_post_fof_closed(content)
.await
.map_err(|e| e.to_string())?;
Ok(FoFPostCreatedDto { post_id: hex::encode(post_id) })
}
/// Returns the decrypted body of a FoFClosed post if any local persona
/// can unlock it. `None` means "ciphertext only" (not in the FoF set).
#[tauri::command]
async fn read_fof_closed_body(
state: State<'_, AppNode>,
post_id_hex: String,
) -> Result<Option<String>, String> {
let node = get_node(&state).await;
let pid = parse_node_id(&post_id_hex)?;
node.read_fof_closed_body(&pid).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn list_follows(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
let node = get_node(&state).await;
@ -3213,6 +3249,8 @@ pub fn run() {
create_post_with_fof_comments,
comment_on_fof_post,
revoke_fof_commenter,
create_post_fof_closed,
read_fof_closed_body,
list_circles,
create_circle,
delete_circle,