fix(relay): resubscribe agents when an archived channel is restored (#1187)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-22 22:16:16 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent d389bf0b85
commit 6780ea21e4
2 changed files with 106 additions and 0 deletions
@@ -1165,6 +1165,39 @@ async fn handle_edit_metadata(event: &Event, state: &Arc<AppState>) -> anyhow::R
}),
)
.await?;
// Resubscribe connected agents after restore: archiving evicts their
// live subscriptions (CLOSED "channel access revoked") and unarchive
// otherwise emits no signal that makes a connected agent resubscribe.
// We reuse the member_added notification (44100) purely as a resubscribe
// trigger — no membership actually changed here — because it flows on the
// agent's always-live global membership subscription, the same path
// remove/re-add uses to recover. Humans self-heal via the re-emitted
// kind:39000 discovery, so this is intentionally agent-scoped.
//
// Known limitation: emit_membership_notification builds a created_at=now
// event with no nonce, and insert_event skips fan-out on a duplicate id.
// Four sub-second toggles (archive->unarchive->archive->unarchive) on the
// same channel by the same actor could collide ids and skip a fan-out.
// Not reachable in practice — unarchive has a single human-driven caller;
// the reaper only auto-archives — so we don't engineer around it.
for member in state.db.get_members(channel_id).await? {
if let Err(e) = emit_membership_notification(
state,
channel_id,
&member.pubkey,
&actor_bytes,
KIND_MEMBER_ADDED_NOTIFICATION,
)
.await
{
warn!(
channel = %channel_id,
error = %e,
"post-unarchive resubscribe notification failed"
);
}
}
}
_ => {} // ignore invalid values
}
@@ -846,6 +846,79 @@ async fn test_nip29_put_user_default_policy_allows() {
ws.disconnect().await.expect("disconnect");
}
/// Restoring an archived channel must re-signal connected members so their
/// agents resubscribe. Archive evicts live channel subscriptions; unarchive
/// emits a kind:44100 member_added notification per member on the always-live
/// global membership feed, which is the resubscribe trigger remove/re-add uses.
#[tokio::test]
#[ignore]
async fn test_unarchive_emits_member_added_notification() {
let url = relay_url();
let owner_keys = Keys::generate();
let owner_pubkey_hex = owner_keys.public_key().to_hex();
// Creating the channel makes the owner its sole member.
let channel_id = create_test_channel(&owner_keys).await;
let mut ws = BuzzTestClient::connect(&url, &owner_keys)
.await
.expect("connect as owner");
// Subscribe to the global membership feed (kind:44100 addressed to the owner).
// This is a global, non-channel-scoped subscription, so archive's
// channel-scoped eviction leaves it intact across the archive→unarchive cycle.
let sid = sub_id("membership-feed");
let membership_filter = Filter::new().kind(Kind::Custom(44100)).custom_tags(
SingleLetterTag::lowercase(Alphabet::P),
[owner_pubkey_hex.as_str()],
);
ws.subscribe(&sid, vec![membership_filter])
.await
.expect("subscribe to membership feed");
ws.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("membership feed EOSE");
// Archive, then unarchive, the channel via kind:9002 edit-metadata.
for archived in ["true", "false"] {
let event = EventBuilder::new(Kind::Custom(9002), "")
.tags([
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["archived", archived]).unwrap(),
])
.sign_with_keys(&owner_keys)
.unwrap();
let ok = ws.send_event(event).await.expect("send kind 9002");
assert!(ok.accepted, "edit-metadata rejected: {}", ok.message);
}
// The unarchive must fan out a 44100 to the owner. Loop past any other
// events delivered on the connection until we see it (or time out).
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
let remaining = deadline
.checked_duration_since(tokio::time::Instant::now())
.filter(|d| !d.is_zero())
.expect("timed out waiting for member_added notification");
if let RelayMessage::Event { event, .. } = ws
.recv_event(remaining)
.await
.expect("recv membership notification")
{
if event.kind == Kind::Custom(44100) {
let content: serde_json::Value =
serde_json::from_str(&event.content).expect("parse notification content");
assert_eq!(content["type"], "member_added");
assert_eq!(content["channel_id"], channel_id);
break;
}
}
}
ws.disconnect().await.expect("disconnect");
}
/// NIP-29 kind 9000 (PUT_USER): "nobody" policy blocks a third party from adding the agent.
#[tokio::test]
#[ignore]