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

@ -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');

View file

@ -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 &mdash; 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>