Skip to content

Commit bf92751

Browse files
JSKittyclaude
andcommitted
fix(v2): restart-proof full Delete — retain wrap scrub keys on send + heal history
publish_chat now retains each durable wrap's signing key (the group stream key, valid for same-author NIP-09 on any relay) keyed by rumor id, so get_message_key answers true after a restart and the shared relay-nuke layer works for v2 messages and reactions. fetch_channel_history self-heals the mapping for OWN rumors seen during backfill — pre-retention and other-device sends regain full Delete — and emits message_delete_meta_changed so the frontend verdict cache re-resolves without an app restart. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 87402cb commit bf92751

2 files changed

Lines changed: 147 additions & 8 deletions

File tree

crates/vector-core/src/community/v2/service.rs

Lines changed: 137 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,8 @@ pub async fn send_edit<T: Transport + ?Sized>(
190190
}
191191

192192
/// Cooperative in-plane delete (kind 5, NIP-09 semantics): peers stop rendering
193-
/// `target_id_hex`. The wrap ciphertext on relays needs a separate NIP-09 scrub
194-
/// by its ephemeral key — not retained in this cut.
193+
/// `target_id_hex`. The wrap ciphertext on relays is scrubbed separately via the
194+
/// retained per-message stream key (see `publish_chat`).
195195
pub async fn send_delete<T: Transport + ?Sized>(
196196
transport: &T,
197197
community: &CommunityV2,
@@ -279,12 +279,23 @@ async fn publish_chat<T: Transport + ?Sized>(
279279
ephemeral: bool,
280280
) -> Result<String, String> {
281281
let rumor_id = rumor.id.ok_or("rumor has no id")?.to_hex();
282-
let (wrap, _ephemeral_keys) = chat::seal_chat_rumor(&rumor, group, author, Timestamp::from_secs(at_ms / 1000), ephemeral)
282+
let (wrap, _p_tag_keys) = chat::seal_chat_rumor(&rumor, group, author, Timestamp::from_secs(at_ms / 1000), ephemeral)
283283
.map_err(|e| e.to_string())?;
284284
if !session.is_valid() {
285285
return Err("account changed before send".to_string());
286286
}
287287
transport.publish(&wrap, &community.relays).await?;
288+
// Retain the wrap's signing key (the group stream key) keyed by rumor id so a
289+
// full delete can NIP-09 this exact wrap off relays (same-author rule, honored
290+
// everywhere — the discarded p-tag pair only works on recipient-delete relays).
291+
// Frozen per-message so later rekeys can't strand it. Session-gated: the publish
292+
// straddled network I/O.
293+
if !ephemeral {
294+
if !session.is_valid() {
295+
return Ok(rumor_id);
296+
}
297+
crate::db::community::store_message_key(&rumor_id, &wrap.id.to_hex(), group.keys(), &community.relays)?;
298+
}
288299
// Local echo (v1 parity): open our OWN wrap through the exact inbound path so
289300
// send-then-read works with no listen loop, and the relay's re-delivery dedups
290301
// against this row instead of re-firing callbacks. Best-effort — the publish
@@ -317,6 +328,33 @@ pub struct FetchedEvent {
317328
pub epoch: Epoch,
318329
}
319330

331+
/// Self-heal scrub-key retention for an OWN rumor seen during a history open:
332+
/// pre-retention and other-device sends stay fully deletable, because the wrap's
333+
/// signing key is the derivable group stream key — only this rumor→wrap mapping
334+
/// was ever missing locally. No-op for foreign authors, kinds the UI can't
335+
/// delete, and already-retained rows. Best-effort: a store failure never breaks
336+
/// the fetch.
337+
fn heal_own_wrap_key(event: &ChatEvent, group: &GroupKey, relays: &[String]) {
338+
if !matches!(event, ChatEvent::Message { .. } | ChatEvent::Reaction { .. }) {
339+
return;
340+
}
341+
let opened = event.opened();
342+
if crate::state::my_public_key() != Some(opened.author) {
343+
return;
344+
}
345+
let rumor_hex = opened.rumor_id.to_hex();
346+
// Only fill a confirmed gap — never clobber a send-time row, never write
347+
// when the store can't be read.
348+
if !matches!(crate::db::community::get_message_key(&rumor_hex), Ok(None)) {
349+
return;
350+
}
351+
if crate::db::community::store_message_key(&rumor_hex, &opened.wrapper_id.to_hex(), group.keys(), relays).is_ok() {
352+
// The UI caches full-vs-limited delete verdicts per message; tell it this
353+
// one just flipped so it re-resolves without an app restart.
354+
crate::traits::emit_event("message_delete_meta_changed", &serde_json::json!({ "id": rumor_hex }));
355+
}
356+
}
357+
320358
/// Fetch a channel's newest messages — one page of [`fetch_channel_history`].
321359
/// `limit` is one relay-side bound across the whole epoch-author OR-set, not
322360
/// per epoch; deeper history pages backwards via the walk.
@@ -350,6 +388,9 @@ pub async fn fetch_channel_history<T: Transport + ?Sized>(
350388
max_pages: usize,
351389
mut keep_paging: impl FnMut(&[FetchedEvent]) -> bool,
352390
) -> Result<Vec<FetchedEvent>, String> {
391+
// Guards the opportunistic scrub-key heals below — the fetch loop straddles
392+
// network I/O, and an account swap must not write into the new account's DB.
393+
let session = SessionGuard::capture();
353394
let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
354395
// A Public channel reads across EVERY held base-root epoch, and a Private one
355396
// across its OWN held epochs (CORD-03 §3), so history spanning a rotation stays
@@ -422,6 +463,9 @@ pub async fn fetch_channel_history<T: Transport + ?Sized>(
422463
if let Ok(event) = chat::open_chat_event(wrap, &group, channel_id, *epoch) {
423464
let id = event.opened().rumor_id;
424465
if seen_rumors.insert(id) {
466+
if session.is_valid() {
467+
heal_own_wrap_key(&event, &group, &community.relays);
468+
}
425469
page_events.push(FetchedEvent { event, epoch: *epoch });
426470
}
427471
}
@@ -7176,15 +7220,100 @@ mod tests {
71767220
});
71777221
send_typing(&bed.relay, &community, &general).await.unwrap();
71787222
let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
7179-
assert!(
7180-
matches!(chat::open_chat_event(&wrap, &group, &general, community.root_epoch), Ok(ChatEvent::Typing { .. })),
7181-
"the ephemeral wrap opens as a Typing event"
7182-
);
7223+
let opened = match chat::open_chat_event(&wrap, &group, &general, community.root_epoch) {
7224+
Ok(ChatEvent::Typing { opened }) => opened,
7225+
other => panic!("the ephemeral wrap must open as a Typing event, got {other:?}"),
7226+
};
71837227

71847228
// …while nothing durable is stored (relays never keep the ephemeral tier),
7185-
// so channel history stays free of typing noise.
7229+
// so channel history stays free of typing noise
71867230
let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
71877231
assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
7232+
7233+
// …and no scrub key is retained (there is no durable wrap to ever delete).
7234+
assert!(
7235+
crate::db::community::get_message_key(&opened.rumor_id.to_hex()).unwrap().is_none(),
7236+
"ephemeral sends must not retain scrub keys"
7237+
);
7238+
}
7239+
7240+
#[tokio::test]
7241+
async fn a_durable_send_retains_the_wrap_scrub_key_and_full_delete_nukes_the_relay_copy() {
7242+
let (bed, owner, _member) = TestBed::new();
7243+
bed.swap_to(&owner);
7244+
let community = create_community(&bed.relay, "Nuke", bed.relays.clone(), None).await.unwrap();
7245+
let general = community.channels[0].id;
7246+
let group = channel_group_key(&community.community_root, &general, community.root_epoch);
7247+
7248+
let id = send_message(&bed.relay, &community, &general, "scrub me").await.unwrap();
7249+
7250+
// Retained: the row maps the rumor id to the exact published wrap, holds the
7251+
// key that SIGNED that wrap (same-author NIP-09), and the relay set.
7252+
let (keys, outer_hex, relays) =
7253+
crate::db::community::get_message_key(&id).unwrap().expect("a durable send retains its scrub key");
7254+
assert_eq!(relays, community.relays);
7255+
let wrap_query = Query {
7256+
kinds: vec![stream::KIND_WRAP],
7257+
authors: vec![group.pk_hex()],
7258+
..Default::default()
7259+
};
7260+
let wraps = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
7261+
let wrap = wraps.iter().find(|w| w.id.to_hex() == outer_hex).expect("retained outer id is the published wrap");
7262+
assert_eq!(keys.public_key(), wrap.pubkey, "retained key is the wrap's author");
7263+
7264+
// Reactions ride the same retention (revoke_reaction's relay-nuke layer).
7265+
let me_hex = owner.keys.public_key().to_hex();
7266+
let rid = send_reaction(&bed.relay, &community, &general, &id, &me_hex, super::super::kind::MESSAGE, "🔥", None)
7267+
.await
7268+
.unwrap();
7269+
assert!(crate::db::community::get_message_key(&rid).unwrap().is_some(), "reaction sends retain too");
7270+
7271+
// The shared v1 delete path (Layer 1 of delete_community_message / revoke_reaction)
7272+
// scrubs the wrap off the relay via the retained key, then consumes the row.
7273+
crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
7274+
assert!(crate::db::community::get_message_key(&id).unwrap().is_none(), "key consumed after the scrub");
7275+
let after = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
7276+
assert!(!after.iter().any(|w| w.id.to_hex() == outer_hex), "wrap scrubbed from the relay");
7277+
}
7278+
7279+
#[tokio::test]
7280+
async fn backfill_heals_scrub_keys_for_own_pre_retention_messages_only() {
7281+
let (bed, owner, _member) = TestBed::new();
7282+
bed.swap_to(&owner);
7283+
let community = create_community(&bed.relay, "Heal", bed.relays.clone(), None).await.unwrap();
7284+
let general = community.channels[0].id;
7285+
let group = channel_group_key(&community.community_root, &general, community.root_epoch);
7286+
7287+
// Simulate a pre-retention / other-device send: our message on the relay,
7288+
// but no local mapping row.
7289+
let id = send_message(&bed.relay, &community, &general, "old send").await.unwrap();
7290+
crate::db::community::delete_message_key(&id).unwrap();
7291+
assert!(crate::db::community::get_message_key(&id).unwrap().is_none());
7292+
7293+
// A stranger member's message rides the same channel.
7294+
let mkeys = Keys::generate();
7295+
let rumor = chat::build_message_rumor(mkeys.public_key(), &general, community.root_epoch, "foreign", None, &[], vec![], 6_000);
7296+
let foreign_id = rumor.id.unwrap().to_hex();
7297+
let (fw, _) = chat::seal_chat_rumor(&rumor, &group, &mkeys, Timestamp::from_secs(6), false).unwrap();
7298+
bed.relay.publish(&fw, &community.relays).await.unwrap();
7299+
7300+
// One history open re-derives the mapping for the OWN message…
7301+
fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7302+
let (keys, _outer, relays) =
7303+
crate::db::community::get_message_key(&id).unwrap().expect("backfill heals own unretained rows");
7304+
assert_eq!(keys.public_key(), group.pk(), "healed key is the wrap's signing key");
7305+
assert_eq!(relays, community.relays);
7306+
7307+
// …and never manufactures one for a foreign author.
7308+
assert!(crate::db::community::get_message_key(&foreign_id).unwrap().is_none());
7309+
7310+
// The healed row is a working full delete: the shared path scrubs the wrap.
7311+
crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
7312+
let left = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7313+
assert!(
7314+
!left.iter().any(|f| f.event.opened().rumor_id.to_hex() == id),
7315+
"healed message scrubbed from the relay"
7316+
);
71887317
}
71897318

71907319
#[tokio::test]

src/main.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4044,6 +4044,16 @@ async function setupRustListeners() {
40444044
invoke('update_unread_counter');
40454045
});
40464046

4047+
// A backend heal flipped a message's full-vs-limited delete verdict (v2 scrub
4048+
// keys re-derived during backfill) — drop the cached verdict so it re-resolves.
4049+
_on('message_delete_meta_changed', (evt) => {
4050+
const id = evt.payload?.id;
4051+
if (!id) return;
4052+
dmsgInvalidateDeleteMeta(id);
4053+
// Row on screen: re-resolve now (a cache fill also refreshes an open toolbar).
4054+
if (document.getElementById(id)) dmsgQueueDeleteMeta([id]);
4055+
});
4056+
40474057
// Listen for headless mark-as-read (e.g., notification "Mark Read" action while app backgrounded)
40484058
_on('chat_mark_read', (evt) => {
40494059
const { chat_id, last_read } = evt.payload;

0 commit comments

Comments
 (0)