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:
parent
d1afcec26a
commit
34c5b60686
4 changed files with 291 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1089,6 +1089,62 @@ async fn list_ignored_peers(state: State<'_, AppNode>) -> Result<Vec<IgnoredPeer
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
// --- FoF Layer 1: Vouches ---
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VouchGivenDto {
|
||||
node_id: String,
|
||||
display_name: String,
|
||||
granted_at_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VouchReceivedDto {
|
||||
node_id: String,
|
||||
display_name: String,
|
||||
epoch: u32,
|
||||
received_at_ms: u64,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn vouch_for_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> {
|
||||
let node = get_node(&state).await;
|
||||
let nid = parse_node_id(&node_id_hex)?;
|
||||
node.vouch_for_peer(&nid).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn revoke_vouch_for_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> {
|
||||
let node = get_node(&state).await;
|
||||
let nid = parse_node_id(&node_id_hex)?;
|
||||
node.revoke_vouch_and_rotate(&nid).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_vouches_given(state: State<'_, AppNode>) -> Result<Vec<VouchGivenDto>, String> {
|
||||
let node = get_node(&state).await;
|
||||
let rows = node.list_vouches_given().await.map_err(|e| e.to_string())?;
|
||||
Ok(rows.into_iter().map(|(nid, name, at)| VouchGivenDto {
|
||||
node_id: hex::encode(nid),
|
||||
display_name: name,
|
||||
granted_at_ms: at,
|
||||
}).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_vouches_received(state: State<'_, AppNode>) -> Result<Vec<VouchReceivedDto>, String> {
|
||||
let node = get_node(&state).await;
|
||||
let rows = node.list_vouches_received().await.map_err(|e| e.to_string())?;
|
||||
Ok(rows.into_iter().map(|(nid, name, epoch, at)| VouchReceivedDto {
|
||||
node_id: hex::encode(nid),
|
||||
display_name: name,
|
||||
epoch,
|
||||
received_at_ms: at,
|
||||
}).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_follows(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
|
||||
let node = get_node(&state).await;
|
||||
|
|
@ -3103,6 +3159,10 @@ pub fn run() {
|
|||
ignore_peer,
|
||||
unignore_peer,
|
||||
list_ignored_peers,
|
||||
vouch_for_peer,
|
||||
revoke_vouch_for_peer,
|
||||
list_vouches_given,
|
||||
list_vouches_received,
|
||||
list_circles,
|
||||
create_circle,
|
||||
delete_circle,
|
||||
|
|
|
|||
|
|
@ -1633,8 +1633,10 @@ async function openBioModal(nodeId, preloadedName) {
|
|||
const resolved = await invoke('resolve_display', { nodeIdHex: nodeId }).catch(() => null);
|
||||
const follows = await invoke('list_follows').catch(() => []);
|
||||
const ignored = await invoke('list_ignored_peers').catch(() => []);
|
||||
const vouches = await invoke('list_vouches_given').catch(() => []);
|
||||
const following = follows.some(f => f.nodeId === nodeId);
|
||||
const isIgnored = ignored.some(i => i.nodeId === nodeId);
|
||||
const isVouched = vouches.some(v => v.nodeId === nodeId);
|
||||
const name = (resolved && resolved.name) || preloadedName || nodeId.slice(0, 12);
|
||||
const bio = (resolved && resolved.bio) || '';
|
||||
const icon = generateIdenticon(nodeId, 48);
|
||||
|
|
@ -1654,6 +1656,9 @@ async function openBioModal(nodeId, preloadedName) {
|
|||
${following
|
||||
? `<button id="bio-unfollow" class="btn btn-ghost btn-sm">Unfollow</button>`
|
||||
: `<button id="bio-follow" class="btn btn-primary btn-sm">Follow</button>`}
|
||||
${isVouched
|
||||
? `<button id="bio-revoke-vouch" class="btn btn-ghost btn-sm">Revoke Vouch</button>`
|
||||
: `<button id="bio-vouch" class="btn btn-ghost btn-sm">Vouch</button>`}
|
||||
<button id="bio-message" class="btn btn-ghost btn-sm">Message</button>
|
||||
${isIgnored
|
||||
? `<button id="bio-unignore" class="btn btn-ghost btn-sm">Unignore</button>`
|
||||
|
|
@ -1700,6 +1705,27 @@ async function openBioModal(nodeId, preloadedName) {
|
|||
try { await invoke('unignore_peer', { nodeIdHex: nodeId }); toast('Unignored'); close(); loadFeed(true); }
|
||||
catch (e) { toast('Error: ' + e); }
|
||||
};
|
||||
const vouch = document.getElementById('bio-vouch');
|
||||
if (vouch) vouch.onclick = async () => {
|
||||
vouch.disabled = true;
|
||||
try {
|
||||
await invoke('vouch_for_peer', { nodeIdHex: nodeId });
|
||||
toast(`Vouched for ${name}`);
|
||||
close();
|
||||
} catch (e) { toast('Error: ' + e); }
|
||||
finally { vouch.disabled = false; }
|
||||
};
|
||||
const revokeVouch = document.getElementById('bio-revoke-vouch');
|
||||
if (revokeVouch) revokeVouch.onclick = async () => {
|
||||
if (!confirm(`Revoke vouch for ${name}? This rotates your vouch key — they keep access to existing posts but not future ones.`)) return;
|
||||
revokeVouch.disabled = true;
|
||||
try {
|
||||
await invoke('revoke_vouch_for_peer', { nodeIdHex: nodeId });
|
||||
toast('Revoked and rotated');
|
||||
close();
|
||||
} catch (e) { toast('Error: ' + e); }
|
||||
finally { revokeVouch.disabled = false; }
|
||||
};
|
||||
} catch (e) {
|
||||
bodyEl.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
||||
}
|
||||
|
|
@ -3062,7 +3088,7 @@ document.querySelectorAll('.tab').forEach(tab => {
|
|||
loadMessages(true); loadDmRecipientOptions();
|
||||
clearNotifications('msg-');
|
||||
}
|
||||
if (target === 'settings') { loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); loadUpdateSettings(); loadIgnored(); }
|
||||
if (target === 'settings') { loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); loadUpdateSettings(); loadIgnored(); loadVouches(); }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -3618,6 +3644,59 @@ async function loadIgnored() {
|
|||
}
|
||||
}
|
||||
|
||||
async function loadVouches() {
|
||||
const givenEl = document.getElementById('vouches-given-list');
|
||||
const recvEl = document.getElementById('vouches-received-list');
|
||||
if (!givenEl || !recvEl) return;
|
||||
try {
|
||||
const [given, received] = await Promise.all([
|
||||
invoke('list_vouches_given').catch(() => []),
|
||||
invoke('list_vouches_received').catch(() => []),
|
||||
]);
|
||||
if (!given || given.length === 0) {
|
||||
givenEl.innerHTML = '<p class="empty-hint" style="margin:0">No vouches given.</p>';
|
||||
} else {
|
||||
givenEl.innerHTML = given.map(v => {
|
||||
const label = escapeHtml(v.displayName || v.nodeId.slice(0, 12));
|
||||
const icon = generateIdenticon(v.nodeId, 18);
|
||||
return `<div class="peer-card" data-node-id="${v.nodeId}">
|
||||
<div class="peer-card-row">${icon} ${label}</div>
|
||||
<div class="peer-card-actions">
|
||||
<button class="btn btn-ghost btn-sm revoke-vouch-btn" data-node-id="${v.nodeId}" data-name="${label}">Revoke</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
givenEl.querySelectorAll('.revoke-vouch-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const name = btn.dataset.name;
|
||||
if (!confirm(`Revoke vouch for ${name}? This rotates your vouch key — they keep access to existing posts but not future ones.`)) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await invoke('revoke_vouch_for_peer', { nodeIdHex: btn.dataset.nodeId });
|
||||
toast('Revoked and rotated');
|
||||
loadVouches();
|
||||
} catch (e) { toast('Error: ' + e); }
|
||||
finally { btn.disabled = false; }
|
||||
});
|
||||
});
|
||||
}
|
||||
if (!received || received.length === 0) {
|
||||
recvEl.innerHTML = '<p class="empty-hint" style="margin:0">No vouches received.</p>';
|
||||
} else {
|
||||
recvEl.innerHTML = received.map(v => {
|
||||
const label = escapeHtml(v.displayName || v.nodeId.slice(0, 12));
|
||||
const icon = generateIdenticon(v.nodeId, 18);
|
||||
return `<div class="peer-card" data-node-id="${v.nodeId}">
|
||||
<div class="peer-card-row">${icon} ${label}</div>
|
||||
<div class="peer-card-actions empty-hint" style="font-size:0.75rem">epoch ${v.epoch}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
} catch (e) {
|
||||
givenEl.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Release announcement / upgrade banner ---
|
||||
async function loadUpgradeBanner() {
|
||||
const banner = document.getElementById('upgrade-banner');
|
||||
|
|
|
|||
|
|
@ -198,6 +198,21 @@
|
|||
<div id="ignored-list" style="text-align:left"></div>
|
||||
</div>
|
||||
|
||||
<div class="section-card" style="text-align:center">
|
||||
<h3 style="margin-bottom:0.25rem">Vouches</h3>
|
||||
<p class="empty-hint" style="margin-bottom:0.5rem">Vouches you've given let those friends read your Friend-of-Friend posts. Vouches you've received unlock the posts of those who vouched for you. Revoking rotates your vouch key — the revoked friend keeps access to your existing posts, but not future ones.</p>
|
||||
<div style="display:flex;gap:1rem;text-align:left">
|
||||
<div style="flex:1;min-width:0">
|
||||
<h4 style="margin:0 0 0.4rem;font-size:0.9rem">Given</h4>
|
||||
<div id="vouches-given-list"></div>
|
||||
</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<h4 style="margin:0 0 0.4rem;font-size:0.9rem">Received</h4>
|
||||
<div id="vouches-received-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card" style="text-align:center">
|
||||
<h3 style="margin-bottom:0.25rem">Updates</h3>
|
||||
<p class="empty-hint" style="margin-bottom:0.5rem">Network-wide release announcements are signed by the bootstrap anchor and arrive via the CDN. Choose which channel to follow.</p>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue