feat(fof-layer4): Tauri commands + Settings "Rotate my vouch key" UI

Three new Tauri commands surface Layer 4 to the frontend:

- rotate_v_me() -> { newEpoch }: generates next V_me epoch +
  republishes bio. Old epoch retained in vouch_keys_own; existing
  vouchees receive the new key on their next bio-scan.
- cascade_revoke_v_me_epoch(retired_epoch, reason_code) ->
  { postsRevoked }: bulk per-post revocation across every author
  post that sealed slots under (self, retired_epoch). Useful as a
  follow-up after rotate_v_me when the author wants to actively
  cut off comment access on old posts.
- key_burn_post_slot(post_id_hex, slot_index, new_v_x_hex): the
  leaked-V_me primitive. Re-seals one slot under a different V_x.

Frontend (Settings → Vouches):
- New "Rotate my vouch key" button + status pill below the Given /
  Received lists.
- Confirmation prompt explains the grandfather-by-default semantics:
  "old posts remain readable to anyone who held the old key — cascade-
  revoke separately if you want to cut off old-content access."
- Wired once per settings-tab activation.

Cascade-revoke and key-burn surfaces aren't visualized yet (require
per-post selection UI); the Tauri commands are available for follow-up
UI work or scripting via the desktop dev console.

Workspace builds clean; 148 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-05-14 19:18:31 -06:00
parent fdbf97f2d7
commit ce710a6596
3 changed files with 89 additions and 1 deletions

View file

@ -1228,6 +1228,57 @@ async fn read_fof_closed_body(
node.read_fof_closed_body(&pid).await.map_err(|e| e.to_string()) node.read_fof_closed_body(&pid).await.map_err(|e| e.to_string())
} }
// FoF Layer 4: V_me lifecycle + cascade + key-burn.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct VmeRotatedDto {
new_epoch: u32,
}
#[tauri::command]
async fn rotate_v_me(state: State<'_, AppNode>) -> Result<VmeRotatedDto, String> {
let node = get_node(&state).await;
let new_epoch = node.rotate_v_me().await.map_err(|e| e.to_string())?;
Ok(VmeRotatedDto { new_epoch })
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CascadeRevokeResultDto {
posts_revoked: usize,
}
#[tauri::command]
async fn cascade_revoke_v_me_epoch(
state: State<'_, AppNode>,
retired_epoch: u32,
reason_code: u8,
) -> Result<CascadeRevokeResultDto, String> {
let node = get_node(&state).await;
let n = node.cascade_revoke_v_me_epoch(retired_epoch, reason_code)
.await
.map_err(|e| e.to_string())?;
Ok(CascadeRevokeResultDto { posts_revoked: n })
}
#[tauri::command]
async fn key_burn_post_slot(
state: State<'_, AppNode>,
post_id_hex: String,
slot_index: u32,
new_v_x_hex: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let pid = parse_node_id(&post_id_hex)?;
let new_v_x_bytes = hex::decode(&new_v_x_hex)
.map_err(|e| format!("invalid new_v_x hex: {}", e))?;
let new_v_x: [u8; 32] = new_v_x_bytes.as_slice().try_into()
.map_err(|_| "new_v_x must be 32 bytes".to_string())?;
node.key_burn_post_slot(pid, slot_index, &new_v_x).await
.map_err(|e| e.to_string())
}
#[tauri::command] #[tauri::command]
async fn list_follows(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> { async fn list_follows(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
let node = get_node(&state).await; let node = get_node(&state).await;
@ -3251,6 +3302,9 @@ pub fn run() {
revoke_fof_commenter, revoke_fof_commenter,
create_post_fof_closed, create_post_fof_closed,
read_fof_closed_body, read_fof_closed_body,
rotate_v_me,
cascade_revoke_v_me_epoch,
key_burn_post_slot,
list_circles, list_circles,
create_circle, create_circle,
delete_circle, delete_circle,

View file

@ -3154,7 +3154,16 @@ document.querySelectorAll('.tab').forEach(tab => {
loadMessages(true); loadDmRecipientOptions(); loadMessages(true); loadDmRecipientOptions();
clearNotifications('msg-'); clearNotifications('msg-');
} }
if (target === 'settings') { loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); loadUpdateSettings(); loadIgnored(); loadVouches(); } if (target === 'settings') {
loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible();
loadCacheSizeSetting(); loadUpdateSettings(); loadIgnored(); loadVouches();
// FoF Layer 4: rotate button wires once per settings open.
const rotateBtn = document.getElementById('rotate-v-me-btn');
if (rotateBtn && !rotateBtn._wired) {
rotateBtn.addEventListener('click', rotateVMe);
rotateBtn._wired = true;
}
}
}); });
}); });
}); });
@ -3710,6 +3719,26 @@ async function loadIgnored() {
} }
} }
async function rotateVMe() {
const btn = document.getElementById('rotate-v-me-btn');
const status = document.getElementById('rotate-v-me-status');
if (!btn) return;
if (!confirm('Rotate your vouch key? A new key will be issued to all current vouchees via your next bio post. Old posts remain readable to anyone who held the old key — cascade-revoke separately if you want to cut off old-content access.')) return;
btn.disabled = true;
status.textContent = 'Rotating…';
try {
const { newEpoch } = await invoke('rotate_v_me');
status.textContent = `Rotated → epoch ${newEpoch}`;
toast(`Vouch key rotated to epoch ${newEpoch}`);
loadVouches();
} catch (e) {
status.textContent = '';
toast('Error: ' + e);
} finally {
btn.disabled = false;
}
}
async function loadVouches() { async function loadVouches() {
const givenEl = document.getElementById('vouches-given-list'); const givenEl = document.getElementById('vouches-given-list');
const recvEl = document.getElementById('vouches-received-list'); const recvEl = document.getElementById('vouches-received-list');

View file

@ -213,6 +213,11 @@
<div id="vouches-received-list"></div> <div id="vouches-received-list"></div>
</div> </div>
</div> </div>
<div style="margin-top:0.75rem;padding-top:0.75rem;border-top:1px solid var(--border)">
<p class="empty-hint" style="margin-bottom:0.4rem">Rotate your vouch key (V_me) to issue a fresh key to all your current vouchees. Old posts remain readable by anyone who held the old key &mdash; use "Cascade revoke" afterward to actively cut off comment access to your old posts.</p>
<button id="rotate-v-me-btn" class="btn btn-ghost btn-sm">Rotate my vouch key</button>
<span id="rotate-v-me-status" class="empty-hint" style="margin-left:0.5rem;font-size:0.8rem"></span>
</div>
</div> </div>
<div class="section-card" style="text-align:center"> <div class="section-card" style="text-align:center">