From ce710a65961c6a7e0b0575eec91cb6adfb332172 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 19:18:31 -0600 Subject: [PATCH] feat(fof-layer4): Tauri commands + Settings "Rotate my vouch key" UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/tauri-app/src/lib.rs | 54 +++++++++++++++++++++++++++++++++++++ frontend/app.js | 31 ++++++++++++++++++++- frontend/index.html | 5 ++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index a1b373a..6246241 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -1228,6 +1228,57 @@ async fn read_fof_closed_body( 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 { + 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 { + 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] async fn list_follows(state: State<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; @@ -3251,6 +3302,9 @@ pub fn run() { revoke_fof_commenter, create_post_fof_closed, read_fof_closed_body, + rotate_v_me, + cascade_revoke_v_me_epoch, + key_burn_post_slot, list_circles, create_circle, delete_circle, diff --git a/frontend/app.js b/frontend/app.js index 5194405..1840b8e 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -3154,7 +3154,16 @@ document.querySelectorAll('.tab').forEach(tab => { loadMessages(true); loadDmRecipientOptions(); 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() { const givenEl = document.getElementById('vouches-given-list'); const recvEl = document.getElementById('vouches-received-list'); diff --git a/frontend/index.html b/frontend/index.html index 82e2b31..e415fd8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -213,6 +213,11 @@
+
+

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 — use "Cascade revoke" afterward to actively cut off comment access to your old posts.

+ + +