feat(fof-layer1): Tauri commands + frontend UI for vouches

Node helpers (crates/core/src/node.rs):
- vouch_for_peer(target): derives target X25519 pub from NodeId,
  inserts into own_vouch_targets, republishes bio post so the new
  VouchGrantBatch propagates to the receiver via CDN.
- revoke_vouch_and_rotate(target): per Scott's design, revocation IS
  the rotation primitive. Marks target current=0, generates new V_me
  epoch in vouch_keys_own (prior retained, Layer 4 receiver-chain),
  republishes bio. Revoked persona retains old V_me → grandfathered
  access to old content; locked out of new content sealed under V_new.
- list_vouches_given / list_vouches_received: enriched with display
  names via resolve_display_name.

Tauri commands (crates/tauri-app/src/lib.rs):
- vouch_for_peer, revoke_vouch_for_peer (single-action commands)
- list_vouches_given, list_vouches_received (DTOs with camelCase)
- All registered in the generate_handler! list.

Frontend (frontend/index.html + app.js):
- New "Vouches" section in Settings tab with side-by-side Given /
  Received lists. Per-row Revoke button on Given entries with confirm
  prompt explaining the rotation semantics.
- Bio modal gains Vouch / Revoke Vouch button next to Follow/Unfollow.
  State derived from list_vouches_given on modal open.
- loadVouches wired into the settings-tab activation handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-05-13 06:47:18 -04:00
parent d1afcec26a
commit 34c5b60686
4 changed files with 291 additions and 1 deletions

View file

@ -1696,6 +1696,142 @@ impl Node {
storage.get_display_name(node_id)
}
// ---- FoF Layer 1: Vouches ----
/// Vouch for a persona from the current default posting identity.
/// Inserts into `own_vouch_targets` and republishes the bio post so
/// the recipient sees the vouch on their next scan.
pub async fn vouch_for_peer(&self, target: &NodeId) -> anyhow::Result<()> {
let (default_id, display_name, bio, avatar_cid, posting_secret) = {
let storage = self.storage.get().await;
let Some(default_id) = storage.get_default_posting_id()? else {
anyhow::bail!("no default posting identity");
};
let pi = storage.get_posting_identity(&default_id)?
.ok_or_else(|| anyhow::anyhow!("default posting identity not in storage"))?;
let profile = storage.get_profile(&default_id)?;
let (name, bio, avatar) = match profile {
Some(p) => (p.display_name, p.bio, p.avatar_cid),
None => (pi.display_name.clone(), String::new(), None),
};
(default_id, name, bio, avatar, pi.secret_seed)
};
// Convert the target's ed25519 NodeId to its X25519 pubkey via
// the same Montgomery derivation receivers use.
let target_x25519_pub = crate::crypto::ed25519_pubkey_to_x25519_public(target)?;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
{
let storage = self.storage.get().await;
storage.upsert_vouch_target(&default_id, target, &target_x25519_pub, now_ms, true)?;
}
// Republish bio post so the new vouch_grants batch propagates.
self.publish_profile_post_as(
&default_id, &posting_secret, &display_name, &bio, avatar_cid,
).await?;
Ok(())
}
/// Revoke a vouch + rotate V_me. Per Scott's design: revocation IS
/// the rotation primitive. The new V_me_epoch is generated and the
/// bio post is republished with wrappers for every remaining target
/// (current=1); the revoked persona only ever held the old V_me, so
/// they're frozen out of future content but retain access to old
/// content (grandfathered) per Layer 4.
pub async fn revoke_vouch_and_rotate(&self, target: &NodeId) -> anyhow::Result<()> {
use rand::RngCore;
let (default_id, display_name, bio, avatar_cid, posting_secret) = {
let storage = self.storage.get().await;
let Some(default_id) = storage.get_default_posting_id()? else {
anyhow::bail!("no default posting identity");
};
let pi = storage.get_posting_identity(&default_id)?
.ok_or_else(|| anyhow::anyhow!("default posting identity not in storage"))?;
let profile = storage.get_profile(&default_id)?;
let (name, bio, avatar) = match profile {
Some(p) => (p.display_name, p.bio, p.avatar_cid),
None => (pi.display_name.clone(), String::new(), None),
};
(default_id, name, bio, avatar, pi.secret_seed)
};
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
{
let storage = self.storage.get().await;
// Soft-revoke: drop from current set (row retained for the
// audit trail + cascade-pickup later if needed).
storage.revoke_vouch_target(&default_id, target)?;
// Rotate V_me: pick the next epoch, insert as current. Prior
// epoch retained (Layer 4 receiver-chain model).
let next_epoch = storage.current_own_vouch_key(&default_id)?
.map(|(e, _)| e + 1)
.unwrap_or(1);
let mut new_key = [0u8; 32];
rand::rng().fill_bytes(&mut new_key);
storage.insert_own_vouch_key(&default_id, next_epoch, &new_key, now_ms)?;
}
// Republish bio post — new V_me wrapped to every still-current target.
self.publish_profile_post_as(
&default_id, &posting_secret, &display_name, &bio, avatar_cid,
).await?;
Ok(())
}
/// List vouches the default persona has issued. Returns
/// `(target_node_id, display_name, granted_at_ms)` tuples.
pub async fn list_vouches_given(&self) -> anyhow::Result<Vec<(NodeId, String, u64)>> {
let (default_id, targets) = {
let storage = self.storage.get().await;
let Some(default_id) = storage.get_default_posting_id()? else {
return Ok(Vec::new());
};
let targets = storage.list_current_vouch_targets(&default_id)?;
(default_id, targets)
};
let _ = default_id;
let mut out = Vec::with_capacity(targets.len());
for (tid, _xpub, at) in targets {
let display = match self.resolve_display_name(&tid).await {
Ok((name, _, _)) if !name.is_empty() => name,
_ => String::new(),
};
out.push((tid, display, at));
}
Ok(out)
}
/// List vouches received by the default persona. Returns
/// `(voucher_node_id, display_name, latest_epoch, latest_received_at_ms)`.
pub async fn list_vouches_received(&self) -> anyhow::Result<Vec<(NodeId, String, u32, u64)>> {
let (default_id, vouchers) = {
let storage = self.storage.get().await;
let Some(default_id) = storage.get_default_posting_id()? else {
return Ok(Vec::new());
};
let vouchers = storage.list_vouchers_for(&default_id)?;
(default_id, vouchers)
};
let _ = default_id;
let mut out = Vec::with_capacity(vouchers.len());
for (owner, epoch, at) in vouchers {
let display = match self.resolve_display_name(&owner).await {
Ok((name, _, _)) if !name.is_empty() => name,
_ => String::new(),
};
out.push((owner, display, epoch, at));
}
Ok(out)
}
// ---- Blobs ----
/// Get a blob by CID from local store.