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

@ -1093,6 +1093,70 @@ mod tests {
assert!(decrypt_fof_body(&encrypted, &cek, &wrong_nonce).is_err());
}
/// End-to-end FoFClosed roundtrip at the helper level: Alice
/// encrypts a body; Bob (with Alice's V_me as a received V_x)
/// trial-unlocks the gating + decrypts the body. Carol (no
/// matching V_x) cannot unlock and the body stays opaque.
#[test]
fn fof_closed_body_end_to_end() {
use crate::types::PostingIdentity;
let s = temp_storage();
// Alice has V_me; she'll author a FoFClosed post.
let (alice_id, alice_seed) = make_persona(70);
s.upsert_posting_identity(&PostingIdentity {
node_id: alice_id, secret_seed: alice_seed,
display_name: "Alice".into(), created_at: 1000,
}).unwrap();
let mut v_me_alice = [0u8; 32];
rand::rng().fill_bytes(&mut v_me_alice);
s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();
// Alice received Bob's V_x — so the gating includes Bob's slot.
let (bob_id, _bob_seed) = make_persona(71);
let mut v_x_bob = [0u8; 32];
rand::rng().fill_bytes(&mut v_x_bob);
s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
let body_plaintext = "secret to the FoF set only";
let body_ct = encrypt_fof_body(body_plaintext, &built.cek, &built.slot_binder_nonce).unwrap();
// Bob's device (with his V_me == v_x_bob) sees the gating block
// and trial-unlocks via his V_me.
let bob_storage = temp_storage();
bob_storage.upsert_posting_identity(&PostingIdentity {
node_id: bob_id, secret_seed: _bob_seed,
display_name: "Bob".into(), created_at: 1500,
}).unwrap();
bob_storage.insert_own_vouch_key(&bob_id, 1, &v_x_bob, 1500).unwrap();
let alice_post = crate::types::Post {
author: alice_id, content: String::new(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
};
let bob_unlock = find_unlock_for_post(&bob_storage, &alice_post).unwrap()
.expect("Bob can unlock");
let bob_decrypted = decrypt_fof_body(&body_ct, &bob_unlock.cek, &built.slot_binder_nonce).unwrap();
assert_eq!(bob_decrypted, body_plaintext);
// Carol has no matching V_x — cannot unlock.
let carol_storage = temp_storage();
let (carol_id, carol_seed) = make_persona(72);
carol_storage.upsert_posting_identity(&PostingIdentity {
node_id: carol_id, secret_seed: carol_seed,
display_name: "Carol".into(), created_at: 1500,
}).unwrap();
let mut v_me_carol = [0u8; 32];
rand::rng().fill_bytes(&mut v_me_carol);
carol_storage.insert_own_vouch_key(&carol_id, 1, &v_me_carol, 1500).unwrap();
let carol_unlock = find_unlock_for_post(&carol_storage, &alice_post).unwrap();
assert!(carol_unlock.is_none(),
"Carol has no matching V_x and cannot unlock the FoFClosed gating");
}
#[test]
fn fof_body_padding_hides_real_length() {
let cek = [0x55; 32];