diff --git a/docs/agents.md b/docs/agents.md index 4b076eaff5..6e032e0bdd 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -553,8 +553,10 @@ store and stops it, keeping its key. The cleanup (`AgentsService.cleanup`, share helpers in `features/agents/relay-removal.ts`) then runs: 1. An exact one-member 9001 (`h`, `p`, `client-id`) goes through the outbox for every - channel whose relay-signed 39002 roster lists the agent, plus the viewer's loaded - channels. Each channel's own fresh roster must then omit the agent. + unarchived channel whose relay-signed 39002 roster lists the agent, plus the + viewer's loaded unarchived channels. Each channel's own fresh roster must then omit + the agent. The relay refuses membership changes in an archived channel, so its + roster keeps the deleted agent. 2. A fresh archive read runs; unless it already lists the identity, it is archived through the request path above when a consent path exists. An agent that never published an owner-attested profile has nothing to hide and is not archived. diff --git a/src/features/agents/relay-removal.test.ts b/src/features/agents/relay-removal.test.ts index 859bf4ac3f..4470f18917 100644 --- a/src/features/agents/relay-removal.test.ts +++ b/src/features/agents/relay-removal.test.ts @@ -147,6 +147,34 @@ it("a refused channel removal does not fail Remove or leave outbox work", async expect(instance.session.outbox?.snapshot()).toEqual([]); }); +it("skips archived channels, where the relay refuses membership changes", async () => { + const active = `${"a".repeat(8)}-channel`, + archived = `${"b".repeat(8)}-channel`; + const { fixture, instance } = setup(viewer, { + [active]: [agent.pubkey, viewer.pubkey], + [archived]: [agent.pubkey, viewer.pubkey], + }); + fixture.archivedChannels.add(archived); + const loaded = [ + { id: active, members: [agent.pubkey] }, + { id: archived, members: [agent.pubkey] }, + ]; + await removeAgentFromChannels( + { + ...instance.session, + channels: { list: () => ({ channels: loaded }) }, + } as unknown as typeof instance.session, + agent.pubkey, + new AbortController().signal, + ); + expect(fixture.published.map((event) => event.tags[0])).toEqual([ + ["h", active], + ]); + expect(fixture.channels[active]).not.toContain(agent.pubkey); + expect(fixture.channels[archived]).toContain(agent.pubkey); + expect(instance.session.outbox?.snapshot()).toEqual([]); +}); + it("signs a new channel removal instead of resending one the relay would refuse as stale", async () => { const { fixture, instance } = setup(); const removeFromChannels = () => diff --git a/src/features/agents/relay-removal.ts b/src/features/agents/relay-removal.ts index 75e126a79e..b355cf2493 100644 --- a/src/features/agents/relay-removal.ts +++ b/src/features/agents/relay-removal.ts @@ -151,22 +151,23 @@ export async function deleteAgentRecord( } /** Base Buzz `removeAgentFromAllChannels` over the relay's rosters for this - * agent plus the viewer's loaded channels. Resolves only once a fresh relay - * read lists the agent in none of them; any refusal or unknown outcome throws - * after every channel operation has settled. */ + * agent plus the viewer's loaded channels, skipping archived ones. Resolves + * only once a fresh relay read lists the agent in none of them; any refusal or + * unknown outcome throws after every channel operation has settled. */ export async function removeAgentFromChannels( session: Pick, pubkey: string, signal: AbortSignal, ) { - const pending = new Set([ - ...(await session.channelWrites.memberChannels(pubkey, signal)), - ...session.channels + const pending = await session.channelWrites.memberChannels( + pubkey, + signal, + session.channels .list() .channels.filter((channel) => channel.members?.includes(pubkey)) .map((channel) => channel.id), - ]); - if (!pending.size) return; + ); + if (!pending.length) return; const outbox = session.outbox; if (!outbox?.supports(9001)) throw new Error("This community cannot remove agents from channels."); @@ -223,9 +224,7 @@ export async function removeAgentFromChannels( // Confirm each channel against its own fresh roster: a cached loaded roster // may still list the agent after the relay removed it. const listed = await Promise.all( - [...pending].map((id) => - session.channelWrites.listsMember(id, pubkey, signal), - ), + pending.map((id) => session.channelWrites.listsMember(id, pubkey, signal)), ); const remaining = listed.filter(Boolean); if (remaining.length) diff --git a/src/features/relay/channel-writes.ts b/src/features/relay/channel-writes.ts index 7488056404..f942c4d832 100644 --- a/src/features/relay/channel-writes.ts +++ b/src/features/relay/channel-writes.ts @@ -1,4 +1,5 @@ import { canonicalDetailsName } from "./channel-details-protocol"; +import { exactLifecycleTag } from "./channel-lifecycle-protocol"; import { channelIdentityUnsupported, channelLabelTags } from "./channel-labels"; import { SOLO_NAMED_LABEL, SOLO_LABEL } from "../solo/labels"; import type { Outbox } from "./outbox"; @@ -341,9 +342,15 @@ export function createChannelWrites( ); return channel; } - /** Channel IDs whose relay-signed roster lists `pubkey`, as far as this viewer - * may read. Throws rather than return a possibly truncated set. */ - async function memberChannels(pubkey: string, caller: AbortSignal) { + /** Unarchived channel IDs among `candidates` and those whose relay-signed + * roster lists `pubkey`, as far as this viewer may read. The relay refuses + * membership changes in an archived channel, where a membership is inert. + * Throws rather than return a possibly truncated set. */ + async function memberChannels( + pubkey: string, + caller: AbortSignal, + candidates: Iterable, + ) { if (!relayAuthor) throw new Error("Channel membership is unavailable."); const limit = 500; const events = await discovery.read( @@ -364,7 +371,7 @@ export function createChannelWrites( ); if (events.length >= limit) throw new Error("Too many channels to confirm. Retry later."); - const ids = new Set(); + const ids = new Set(candidates); for (const event of events) { const id = event.tags.find(([name]) => name === "d")?.[1]; if ( @@ -375,6 +382,33 @@ export function createChannelWrites( ) ids.add(id); } + if (!ids.size) return []; + const metadata = await discovery.read( + [ + { + kinds: [39000], + authors: [relayAuthor], + "#d": [...ids], + limit: ids.size, + consistency: "strong", + }, + ], + { + signal: AbortSignal.any([signal, caller]), + fresh: true, + priority: "foreground", + }, + ); + for (const event of metadata) { + const id = event.tags.find(([name]) => name === "d")?.[1]; + if ( + id && + event.kind === 39000 && + event.pubkey === relayAuthor && + exactLifecycleTag(event, "archived") === "true" + ) + ids.delete(id); + } return [...ids]; } /** Whether this channel's current relay-signed roster lists `pubkey`. Throws diff --git a/src/features/relay/testing.ts b/src/features/relay/testing.ts index 799f39db18..b98aa1db09 100644 --- a/src/features/relay/testing.ts +++ b/src/features/relay/testing.ts @@ -5,6 +5,7 @@ import { type EventTemplate, } from "nostr-tools"; import type { ReadFilter, RelayEvent } from "./events"; +import { PublishRejected } from "./outbox"; import type { ReadTransport, Signer } from "./transport"; /** Test-only signing helpers. Real signatures so the transport's verification stays on. */ @@ -175,6 +176,8 @@ export function archiveRelay( channels: Record = {}, ) { const archived = new Set(); + /** Channel IDs whose metadata is archived; the relay refuses 9001 there. */ + const archivedChannels = new Set(); /** NIP-09 coordinates the relay accepted for deletion. */ const deleted = new Set(); const published: RelayEvent[] = []; @@ -241,6 +244,7 @@ export function archiveRelay( tags: [ ["d", id], ["name", id.slice(0, 8)], + ...(archivedChannels.has(id) ? [["archived", "true"]] : []), ], }), ); @@ -276,6 +280,10 @@ export function archiveRelay( } const tag = (name: string) => event.tags.find(([key]) => key === name)?.[1] ?? ""; + if (archivedChannels.has(tag("h"))) { + published.pop(); + throw new PublishRejected("invalid: channel is archived"); + } channels[tag("h")] = (channels[tag("h")] ?? []).filter( (key) => key !== tag("p"), ); @@ -317,6 +325,7 @@ export function archiveRelay( signedBy, script, channels, + archivedChannels, removal, deleted, };