Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions src/features/agents/relay-removal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () =>
Expand Down
21 changes: 10 additions & 11 deletions src/features/agents/relay-removal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RelaySession, "outbox" | "channels" | "channelWrites">,
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.");
Expand Down Expand Up @@ -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)
Expand Down
42 changes: 38 additions & 4 deletions src/features/relay/channel-writes.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string>,
) {
if (!relayAuthor) throw new Error("Channel membership is unavailable.");
const limit = 500;
const events = await discovery.read(
Expand All @@ -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<string>();
const ids = new Set(candidates);
for (const event of events) {
const id = event.tags.find(([name]) => name === "d")?.[1];
if (
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/features/relay/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -175,6 +176,8 @@ export function archiveRelay(
channels: Record<string, string[]> = {},
) {
const archived = new Set<string>();
/** Channel IDs whose metadata is archived; the relay refuses 9001 there. */
const archivedChannels = new Set<string>();
/** NIP-09 coordinates the relay accepted for deletion. */
const deleted = new Set<string>();
const published: RelayEvent[] = [];
Expand Down Expand Up @@ -241,6 +244,7 @@ export function archiveRelay(
tags: [
["d", id],
["name", id.slice(0, 8)],
...(archivedChannels.has(id) ? [["archived", "true"]] : []),
],
}),
);
Expand Down Expand Up @@ -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"),
);
Expand Down Expand Up @@ -317,6 +325,7 @@ export function archiveRelay(
signedBy,
script,
channels,
archivedChannels,
removal,
deleted,
};
Expand Down
Loading