@@ -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`) .
195195pub 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]
0 commit comments