From 21f4e45a3bf2a732124a4e6df925f08b26f5bae2 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 31 Jul 2026 09:32:30 +0100 Subject: [PATCH] fix: verify Buzz relay membership identity --- .../buzz/src/buzz-bus.lifecycle.test.ts | 13 ++- extensions/buzz/src/buzz-bus.ts | 12 ++- extensions/buzz/src/directory-relay.test.ts | 4 + extensions/buzz/src/directory-relay.ts | 8 +- extensions/buzz/src/directory.test.ts | 29 ++++++- extensions/buzz/src/directory.ts | 6 +- extensions/buzz/src/qa/relay-client.test.ts | 11 +++ extensions/buzz/src/qa/relay-client.ts | 22 +++-- extensions/buzz/src/relay-auth.ts | 86 +++++++++++++++++-- extensions/buzz/src/room-access-wait.ts | 5 +- extensions/buzz/src/room-discovery.test.ts | 31 +++++-- extensions/buzz/src/room-discovery.ts | 17 +++- extensions/buzz/src/room-membership-query.ts | 5 +- extensions/buzz/src/room-membership.ts | 7 +- 14 files changed, 220 insertions(+), 36 deletions(-) diff --git a/extensions/buzz/src/buzz-bus.lifecycle.test.ts b/extensions/buzz/src/buzz-bus.lifecycle.test.ts index 25df2605a0db..8d5e16b226fe 100644 --- a/extensions/buzz/src/buzz-bus.lifecycle.test.ts +++ b/extensions/buzz/src/buzz-bus.lifecycle.test.ts @@ -101,6 +101,7 @@ const CHANNEL_ID = "7c4a6d2a-2ed9-4b4e-a5e2-4d705ee9b34c"; const SECOND_CHANNEL_ID = "45cedd86-f853-45b7-8fea-812b7fe63d7a"; const BOT_PUBLIC_KEY = getPublicKey(Uint8Array.from(Buffer.from(PRIVATE_KEY, "hex"))); const SENDER_PUBLIC_KEY = getPublicKey(Uint8Array.from(Buffer.from(SENDER_PRIVATE_KEY, "hex"))); +const RELAY_PUBLIC_KEY = "f".repeat(64); const tempDirs = new Set(); let previousStateDir: string | undefined; let stateDir: string; @@ -127,7 +128,7 @@ describe("Buzz bus lifecycle", () => { { id: "membership-1", kind: 39002, - pubkey: "f".repeat(64), + pubkey: RELAY_PUBLIC_KEY, created_at: 1_700_000_000, content: "", sig: "e".repeat(128), @@ -143,6 +144,16 @@ describe("Buzz bus lifecycle", () => { relayMocks.publish.mockResolvedValue(""); relayMocks.send.mockResolvedValue(); relayMocks.connected = true; + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ + self: RELAY_PUBLIC_KEY, + software: "https://github.com/block/buzz", + }), + })), + ); }); afterEach(() => { diff --git a/extensions/buzz/src/buzz-bus.ts b/extensions/buzz/src/buzz-bus.ts index 60289dba3add..5f5744c98dee 100644 --- a/extensions/buzz/src/buzz-bus.ts +++ b/extensions/buzz/src/buzz-bus.ts @@ -11,7 +11,11 @@ import { type BuzzInboundMessage, } from "./message-event.js"; import { syncBuzzProfile } from "./profile.js"; -import { connectAuthenticatedBuzzRelay, parseBuzzAuthTag } from "./relay-auth.js"; +import { + connectAuthenticatedBuzzRelay, + connectAuthenticatedBuzzRelaySession, + parseBuzzAuthTag, +} from "./relay-auth.js"; import { openBuzzRelaySubscription } from "./relay-subscription.js"; import { queryBuzzRoomMemberships } from "./room-membership-query.js"; import { @@ -176,6 +180,7 @@ async function sleepWithSignal(delayMs: number, signal?: AbortSignal): Promise event.id, namespace: () => options.accountId, }); - const relay = await connectAuthenticatedBuzzRelay({ + const { relay, relayPublicKey } = await connectAuthenticatedBuzzRelaySession({ relayUrl: options.relayUrl, secretKey, authTag, @@ -601,6 +607,7 @@ export async function startBuzzBus(options: { try { const membershipTracker = await createBuzzRoomMembershipTracker({ relay, + relayPublicKey, channelIds: options.channelIds, botPublicKey: publicKey, since: sessionStartedAt, @@ -646,6 +653,7 @@ export async function startBuzzBus(options: { directory.replaceMemberships(membershipTracker.memberships()); directoryRelay = startBuzzDirectoryRelay({ relay, + relayPublicKey, state: directory, signal, onError: options.onDirectoryError, diff --git a/extensions/buzz/src/directory-relay.test.ts b/extensions/buzz/src/directory-relay.test.ts index 4f20d3726a5c..3f709bafe1ed 100644 --- a/extensions/buzz/src/directory-relay.test.ts +++ b/extensions/buzz/src/directory-relay.test.ts @@ -14,6 +14,7 @@ type SubscriptionRecord = { }; const BOT_PUBLIC_KEY = "a".repeat(64); +const RELAY_PUBLIC_KEY = "f".repeat(64); const FIRST_MEMBER_PUBLIC_KEY = "b".repeat(64); const SECOND_MEMBER_PUBLIC_KEY = "c".repeat(64); const LATEST_MEMBER_PUBLIC_KEY = "d".repeat(64); @@ -41,6 +42,7 @@ describe("Buzz directory relay", () => { } as unknown as Relay; const directory = startBuzzDirectoryRelay({ relay, + relayPublicKey: RELAY_PUBLIC_KEY, state: new BuzzDirectoryState({ publicKey: BOT_PUBLIC_KEY, fallbackProfileName: "OpenClaw", @@ -90,6 +92,7 @@ describe("Buzz directory relay", () => { } as unknown as Relay; const directory = startBuzzDirectoryRelay({ relay, + relayPublicKey: RELAY_PUBLIC_KEY, state: new BuzzDirectoryState({ publicKey: BOT_PUBLIC_KEY, fallbackProfileName: "OpenClaw", @@ -124,6 +127,7 @@ describe("Buzz directory relay", () => { } as unknown as Relay; const query = queryBuzzDirectoryRooms({ relay, + relayPublicKey: RELAY_PUBLIC_KEY, state: new BuzzDirectoryState({ publicKey: BOT_PUBLIC_KEY, fallbackProfileName: "OpenClaw", diff --git a/extensions/buzz/src/directory-relay.ts b/extensions/buzz/src/directory-relay.ts index 47114dbc4f1f..28f7ff0a7a30 100644 --- a/extensions/buzz/src/directory-relay.ts +++ b/extensions/buzz/src/directory-relay.ts @@ -109,6 +109,7 @@ export async function queryBuzzDirectoryProfiles(params: { export async function queryBuzzDirectoryRooms(params: { relay: Relay; + relayPublicKey: string; state: BuzzDirectoryState; channelIds: string[]; signal?: AbortSignal; @@ -118,11 +119,14 @@ export async function queryBuzzDirectoryRooms(params: { relay: params.relay, filter: { kinds: [BUZZ_ROOM_METADATA_KIND], + authors: [params.relayPublicKey], "#d": roomIds, limit: roomIds.length, }, onEvent: (event) => { - params.state.applyRoomEvent(event); + if (event.pubkey.toLowerCase() === params.relayPublicKey) { + params.state.applyRoomEvent(event); + } }, signal: params.signal, }); @@ -131,6 +135,7 @@ export async function queryBuzzDirectoryRooms(params: { export function startBuzzDirectoryRelay(params: { relay: Relay; + relayPublicKey: string; state: BuzzDirectoryState; signal?: AbortSignal; onError?: (error: Error) => void; @@ -273,6 +278,7 @@ export function startBuzzDirectoryRelay(params: { pendingRoomIds.clear(); await queryBuzzDirectoryRooms({ relay: params.relay, + relayPublicKey: params.relayPublicKey, state: params.state, channelIds: nextRoomIds, signal: params.signal, diff --git a/extensions/buzz/src/directory.test.ts b/extensions/buzz/src/directory.test.ts index 1b08f6646c6a..b6c227460fb0 100644 --- a/extensions/buzz/src/directory.test.ts +++ b/extensions/buzz/src/directory.test.ts @@ -64,6 +64,7 @@ vi.mock("./gateway.js", () => ({ const PRIVATE_KEY = "11".repeat(32); const BOT_PUBLIC_KEY = getPublicKey(Uint8Array.from(Buffer.from(PRIVATE_KEY, "hex"))); const MEMBER_PUBLIC_KEY = "b".repeat(64); +const RELAY_PUBLIC_KEY = "c".repeat(64); const ROOM_ID = "7c4a6d2a-2ed9-4b4e-a5e2-4d705ee9b34c"; function event(params: Partial & Pick): Event { @@ -83,6 +84,16 @@ describe("Buzz live directory", () => { vi.clearAllMocks(); relayMocks.filters.length = 0; gatewayMocks.activeBus = undefined; + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ + self: RELAY_PUBLIC_KEY, + software: "https://github.com/block/buzz", + }), + })), + ); relayMocks.subscribe.mockImplementation( ( filter: Filter, @@ -95,7 +106,7 @@ describe("Buzz live directory", () => { handlers.onevent( event({ kind: 39_002, - pubkey: "c".repeat(64), + pubkey: RELAY_PUBLIC_KEY, tags: [ ["d", ROOM_ID], ["p", BOT_PUBLIC_KEY, "", "bot"], @@ -107,7 +118,7 @@ describe("Buzz live directory", () => { handlers.onevent( event({ kind: 39_000, - pubkey: "c".repeat(64), + pubkey: RELAY_PUBLIC_KEY, tags: [ ["d", ROOM_ID], ["name", "Engineering"], @@ -161,8 +172,18 @@ describe("Buzz live directory", () => { ]); expect(relayMocks.filters).toEqual([ - { kinds: [39_002], "#d": [ROOM_ID], limit: 1 }, - { kinds: [39_000], "#d": [ROOM_ID], limit: 1 }, + { + kinds: [39_002], + authors: [RELAY_PUBLIC_KEY], + "#d": [ROOM_ID], + limit: 1, + }, + { + kinds: [39_000], + authors: [RELAY_PUBLIC_KEY], + "#d": [ROOM_ID], + limit: 1, + }, { kinds: [0], authors: [BOT_PUBLIC_KEY, MEMBER_PUBLIC_KEY], limit: 2 }, ]); expect(relayMocks.auth).toHaveBeenCalledOnce(); diff --git a/extensions/buzz/src/directory.ts b/extensions/buzz/src/directory.ts index 630001e672de..e07dbd3cce4e 100644 --- a/extensions/buzz/src/directory.ts +++ b/extensions/buzz/src/directory.ts @@ -5,7 +5,7 @@ import type { import { queryBuzzDirectoryProfiles, queryBuzzDirectoryRooms } from "./directory-relay.js"; import { BuzzDirectoryState } from "./directory-state.js"; import { getActiveBuzzBus } from "./gateway.js"; -import { connectAuthenticatedBuzzRelay, parseBuzzAuthTag } from "./relay-auth.js"; +import { connectAuthenticatedBuzzRelaySession, parseBuzzAuthTag } from "./relay-auth.js"; import { queryBuzzRoomMemberships } from "./room-membership-query.js"; import { parseBuzzTarget } from "./target.js"; import { decodeBuzzPrivateKey, resolveBuzzAccount } from "./types.js"; @@ -56,7 +56,7 @@ async function loadBuzzDirectoryState( } const timeoutSignal = AbortSignal.timeout(DIRECTORY_LIVE_TIMEOUT_MS); - const relay = await connectAuthenticatedBuzzRelay({ + const { relay, relayPublicKey } = await connectAuthenticatedBuzzRelaySession({ relayUrl: configured.account.relayUrl, secretKey: decodeBuzzPrivateKey(configured.account.privateKey), authTag: parseBuzzAuthTag(configured.account.authTag), @@ -66,6 +66,7 @@ async function loadBuzzDirectoryState( configured.state.replaceMemberships( await queryBuzzRoomMemberships({ relay, + relayPublicKey, channelIds: configured.channelIds, signal: timeoutSignal, }), @@ -73,6 +74,7 @@ async function loadBuzzDirectoryState( await Promise.all([ queryBuzzDirectoryRooms({ relay, + relayPublicKey, state: configured.state, channelIds: configured.channelIds, signal: timeoutSignal, diff --git a/extensions/buzz/src/qa/relay-client.test.ts b/extensions/buzz/src/qa/relay-client.test.ts index 1f3d9015ae07..f261beea8bb6 100644 --- a/extensions/buzz/src/qa/relay-client.test.ts +++ b/extensions/buzz/src/qa/relay-client.test.ts @@ -74,6 +74,7 @@ vi.mock("nostr-tools", async (importOriginal) => { import { createBuzzQaRelayDriver } from "./relay-client.js"; +const RELAY_PUBLIC_KEY = "f".repeat(64); const credentials = parseBuzzQaCredentialPayload({ relayUrl: "wss://relay.qa.example", roomId: "123e4567-e89b-42d3-a456-426614174000", @@ -86,6 +87,16 @@ describe("Buzz QA relay driver", () => { vi.clearAllMocks(); relayMocks.subscriptions.length = 0; relayMocks.replayedMessage = undefined; + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ + self: RELAY_PUBLIC_KEY, + software: "https://github.com/block/buzz", + }), + })), + ); }); it("authenticates, verifies membership, and publishes a native mentioned thread event", async () => { diff --git a/extensions/buzz/src/qa/relay-client.ts b/extensions/buzz/src/qa/relay-client.ts index aa9c6e0a4b14..5ed0f27d68a5 100644 --- a/extensions/buzz/src/qa/relay-client.ts +++ b/extensions/buzz/src/qa/relay-client.ts @@ -4,7 +4,7 @@ import { parseBuzzMessageEvent, type BuzzInboundMessage, } from "../message-event.js"; -import { connectAuthenticatedBuzzRelay, parseBuzzAuthTag } from "../relay-auth.js"; +import { connectAuthenticatedBuzzRelaySession, parseBuzzAuthTag } from "../relay-auth.js"; import { openBuzzRelaySubscription } from "../relay-subscription.js"; import { BUZZ_ROOM_MEMBERSHIP_KIND, @@ -32,6 +32,7 @@ type BuzzQaRelayDriver = { async function loadBuzzQaRoomMembership(params: { relay: Relay; + relayPublicKey: string; roomId: string; }): Promise { return await new Promise((resolve, reject) => { @@ -59,10 +60,17 @@ async function loadBuzzQaRoomMembership(params: { ); subscriptionRef.current = openBuzzRelaySubscription( params.relay, - [{ kinds: [BUZZ_ROOM_MEMBERSHIP_KIND], "#d": [params.roomId], limit: 1 }], + [ + { + kinds: [BUZZ_ROOM_MEMBERSHIP_KIND], + authors: [params.relayPublicKey], + "#d": [params.roomId], + limit: 1, + }, + ], { onevent: (event) => { - const membership = parseBuzzRoomMembershipEvent(event); + const membership = parseBuzzRoomMembershipEvent(event, params.relayPublicKey); if ( membership?.roomId === params.roomId && isNewerBuzzRoomMembership(membership, latest) @@ -110,7 +118,7 @@ export async function createBuzzQaRelayDriver(params: { let transportError: Error | undefined; let messageQueue = Promise.resolve(); const observedEventIds = new Set(); - const relay = await connectAuthenticatedBuzzRelay({ + const { relay, relayPublicKey } = await connectAuthenticatedBuzzRelaySession({ relayUrl: credentials.relayUrl, secretKey, authTag: parseBuzzAuthTag(credentials.driverAuthTag ?? ""), @@ -118,7 +126,11 @@ export async function createBuzzQaRelayDriver(params: { }); try { assertBuzzQaMembership( - await loadBuzzQaRoomMembership({ relay, roomId: credentials.roomId }), + await loadBuzzQaRoomMembership({ + relay, + relayPublicKey, + roomId: credentials.roomId, + }), credentials, ); } catch (error) { diff --git a/extensions/buzz/src/relay-auth.ts b/extensions/buzz/src/relay-auth.ts index 369f4784cfb5..3b88b905eecf 100644 --- a/extensions/buzz/src/relay-auth.ts +++ b/extensions/buzz/src/relay-auth.ts @@ -2,6 +2,17 @@ import { type EventTemplate, finalizeEvent, Relay, type VerifiedEvent } from "no const AUTH_CHALLENGE_TIMEOUT_MS = 20_000; const AUTH_CHALLENGE_POLL_MS = 25; +const HEX_PUBLIC_KEY_PATTERN = /^[0-9a-f]{64}$/u; +const BUZZ_RELAY_SOFTWARE = "https://github.com/block/buzz"; +// Buzz `just dev` uses private key 1 when auth tokens are disabled, but omits +// NIP-11 `self` because no production relay key was configured. +const BUZZ_LOCAL_DEV_RELAY_PUBLIC_KEY = + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + +export type AuthenticatedBuzzRelaySession = { + relay: Relay; + relayPublicKey: string; +}; export function parseBuzzAuthTag(raw: string): string[] | undefined { if (!raw.trim()) { @@ -49,6 +60,54 @@ function createBuzzAuthSigner(params: { ); } +function isLoopbackRelayUrl(relayUrl: string): boolean { + const hostname = new URL(relayUrl).hostname.toLowerCase(); + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"; +} + +async function resolveBuzzRelayPublicKey(params: { + relayUrl: string; + signal?: AbortSignal; +}): Promise { + const infoUrl = new URL(params.relayUrl); + infoUrl.protocol = infoUrl.protocol === "wss:" ? "https:" : "http:"; + const response = await fetch(infoUrl, { + headers: { Accept: "application/nostr+json" }, + signal: params.signal, + }); + if (!response.ok) { + throw new Error(`Buzz relay information request failed with HTTP ${response.status}`); + } + const document = (await response.json()) as { + self?: unknown; + software?: unknown; + }; + const relayPublicKey = + typeof document.self === "string" ? document.self.trim().toLowerCase() : ""; + if (HEX_PUBLIC_KEY_PATTERN.test(relayPublicKey)) { + return relayPublicKey; + } + if (document.software === BUZZ_RELAY_SOFTWARE && isLoopbackRelayUrl(params.relayUrl)) { + return BUZZ_LOCAL_DEV_RELAY_PUBLIC_KEY; + } + throw new Error("Buzz relay information document is missing a valid NIP-11 self public key"); +} + +async function connectAndAuthenticateBuzzRelay(params: { + relay: Relay; + secretKey: Uint8Array; + authTag?: string[]; + signal?: AbortSignal; +}): Promise { + const signAuth = createBuzzAuthSigner({ + secretKey: params.secretKey, + authTag: params.authTag, + }); + await params.relay.connect({ abort: params.signal }); + await authenticateBuzzRelay({ relay: params.relay, signAuth, signal: params.signal }); + params.relay.onauth = signAuth; +} + export async function connectAuthenticatedBuzzRelay(params: { relayUrl: string; secretKey: Uint8Array; @@ -56,14 +115,8 @@ export async function connectAuthenticatedBuzzRelay(params: { signal?: AbortSignal; }): Promise { const relay = new Relay(params.relayUrl, { enableReconnect: false }); - const signAuth = createBuzzAuthSigner({ - secretKey: params.secretKey, - authTag: params.authTag, - }); try { - await relay.connect({ abort: params.signal }); - await authenticateBuzzRelay({ relay, signAuth, signal: params.signal }); - relay.onauth = signAuth; + await connectAndAuthenticateBuzzRelay({ ...params, relay }); return relay; } catch (error) { relay.close(); @@ -71,6 +124,25 @@ export async function connectAuthenticatedBuzzRelay(params: { } } +export async function connectAuthenticatedBuzzRelaySession(params: { + relayUrl: string; + secretKey: Uint8Array; + authTag?: string[]; + signal?: AbortSignal; +}): Promise { + const relay = new Relay(params.relayUrl, { enableReconnect: false }); + try { + const [, relayPublicKey] = await Promise.all([ + connectAndAuthenticateBuzzRelay({ ...params, relay }), + resolveBuzzRelayPublicKey(params), + ]); + return { relay, relayPublicKey }; + } catch (error) { + relay.close(); + throw error; + } +} + async function authenticateBuzzRelay(params: { relay: Relay; signAuth: (template: EventTemplate) => Promise; diff --git a/extensions/buzz/src/room-access-wait.ts b/extensions/buzz/src/room-access-wait.ts index d90e0f4ef075..4d761fe845b3 100644 --- a/extensions/buzz/src/room-access-wait.ts +++ b/extensions/buzz/src/room-access-wait.ts @@ -1,5 +1,5 @@ import type { Event, Relay } from "nostr-tools"; -import { connectAuthenticatedBuzzRelay, parseBuzzAuthTag } from "./relay-auth.js"; +import { connectAuthenticatedBuzzRelaySession, parseBuzzAuthTag } from "./relay-auth.js"; import { openBuzzRelaySubscription } from "./relay-subscription.js"; import { discoverBuzzRoomsOnRelay, type BuzzDiscoveredRoom } from "./room-discovery.js"; import { BUZZ_CHANNEL_ID_PATTERN } from "./target.js"; @@ -61,7 +61,7 @@ export async function waitForBuzzRoomAccess(params: { const publicKey = resolveBuzzPublicKey(params.privateKey); const timeoutSignal = AbortSignal.timeout(params.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS); const signal = params.signal ? AbortSignal.any([params.signal, timeoutSignal]) : timeoutSignal; - const relay = await connectAuthenticatedBuzzRelay({ + const { relay, relayPublicKey } = await connectAuthenticatedBuzzRelaySession({ relayUrl: params.relayUrl, secretKey, authTag: parseBuzzAuthTag(params.authTag ?? ""), @@ -117,6 +117,7 @@ export async function waitForBuzzRoomAccess(params: { try { const rooms = await discoverBuzzRoomsOnRelay({ relay, + relayPublicKey, publicKey, timeoutMs: 10_000, signal, diff --git a/extensions/buzz/src/room-discovery.test.ts b/extensions/buzz/src/room-discovery.test.ts index 59c7382adaf1..219c07195ad4 100644 --- a/extensions/buzz/src/room-discovery.test.ts +++ b/extensions/buzz/src/room-discovery.test.ts @@ -47,6 +47,7 @@ vi.mock("nostr-tools", async (importOriginal) => { }); const PRIVATE_KEY = "11".repeat(32); +const RELAY_PUBLIC_KEY = "f".repeat(64); const ROOM_A = "7c4a6d2a-2ed9-4b4e-a5e2-4d705ee9b34c"; const ROOM_B = "940d0c32-4eb7-46d7-9d5b-d975aaef87f7"; const AUTH_TAG = ["auth", "bot", "kind=9", "signature"]; @@ -58,6 +59,16 @@ describe("discoverBuzzRooms", () => { relayMocks.connect.mockClear(); relayMocks.filters.length = 0; relayMocks.subscribe.mockReset(); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ + self: RELAY_PUBLIC_KEY, + software: "https://github.com/block/buzz", + }), + })), + ); }); it("discovers only rooms whose member event names the bot public key", async () => { @@ -76,7 +87,7 @@ describe("discoverBuzzRooms", () => { handlers.onevent({ id: "member-a", kind: 39002, - pubkey: "relay", + pubkey: RELAY_PUBLIC_KEY, created_at: 1, content: "", sig: "sig", @@ -88,7 +99,7 @@ describe("discoverBuzzRooms", () => { handlers.onevent({ id: "member-b-wrong-role", kind: 39002, - pubkey: "relay", + pubkey: RELAY_PUBLIC_KEY, created_at: 1, content: "", sig: "sig", @@ -109,7 +120,7 @@ describe("discoverBuzzRooms", () => { handlers.onevent({ id: "metadata-a", kind: 39000, - pubkey: "relay", + pubkey: RELAY_PUBLIC_KEY, created_at: 2, content: "", sig: "sig", @@ -140,8 +151,18 @@ describe("discoverBuzzRooms", () => { ]); expect(relayMocks.filters).toEqual([ - { kinds: [39002], "#p": [publicKey], limit: 1000 }, - { kinds: [39000], "#d": [ROOM_A], limit: 1 }, + { + kinds: [39002], + authors: [RELAY_PUBLIC_KEY], + "#p": [publicKey], + limit: 1000, + }, + { + kinds: [39000], + authors: [RELAY_PUBLIC_KEY], + "#d": [ROOM_A], + limit: 1, + }, ]); expect(relayMocks.auth).toHaveBeenCalledOnce(); expect(signedAuthTags).toContainEqual(AUTH_TAG); diff --git a/extensions/buzz/src/room-discovery.ts b/extensions/buzz/src/room-discovery.ts index 942fda5f7ed0..ce9001bd1a08 100644 --- a/extensions/buzz/src/room-discovery.ts +++ b/extensions/buzz/src/room-discovery.ts @@ -1,5 +1,5 @@ import type { Event, Filter, Relay } from "nostr-tools"; -import { connectAuthenticatedBuzzRelay, parseBuzzAuthTag } from "./relay-auth.js"; +import { connectAuthenticatedBuzzRelaySession, parseBuzzAuthTag } from "./relay-auth.js"; import { openBuzzRelaySubscription } from "./relay-subscription.js"; import { BUZZ_ROOM_MEMBERSHIP_KIND, parseBuzzRoomMembershipEvent } from "./room-membership.js"; import { BUZZ_CHANNEL_ID_PATTERN } from "./target.js"; @@ -73,6 +73,7 @@ async function queryRelay(params: { export async function discoverBuzzRoomsOnRelay(params: { relay: Relay; + relayPublicKey: string; publicKey: string; timeoutMs?: number; signal?: AbortSignal; @@ -82,6 +83,7 @@ export async function discoverBuzzRoomsOnRelay(params: { relay: params.relay, filter: { kinds: [BUZZ_ROOM_MEMBERSHIP_KIND], + authors: [params.relayPublicKey], "#p": [params.publicKey], limit: 1000, }, @@ -91,7 +93,7 @@ export async function discoverBuzzRoomsOnRelay(params: { const roomIds = [ ...new Set( membershipEvents - .map(parseBuzzRoomMembershipEvent) + .map((event) => parseBuzzRoomMembershipEvent(event, params.relayPublicKey)) .filter((membership) => membership?.roles.get(params.publicKey) === "bot") .map((membership) => membership?.roomId) .filter((roomId): roomId is string => Boolean(roomId?.match(BUZZ_CHANNEL_ID_PATTERN))), @@ -103,7 +105,12 @@ export async function discoverBuzzRoomsOnRelay(params: { const metadataEvents = await queryRelay({ relay: params.relay, - filter: { kinds: [METADATA_KIND], "#d": roomIds, limit: roomIds.length }, + filter: { + kinds: [METADATA_KIND], + authors: [params.relayPublicKey], + "#d": roomIds, + limit: roomIds.length, + }, timeoutMs, signal: params.signal, }); @@ -112,6 +119,7 @@ export async function discoverBuzzRoomsOnRelay(params: { const roomId = tagValue(event, "d")?.toLowerCase(); if ( event.kind !== METADATA_KIND || + event.pubkey.toLowerCase() !== params.relayPublicKey || !roomId || !roomIds.includes(roomId) || (latestMetadata.get(roomId)?.created_at ?? -1) >= event.created_at @@ -150,7 +158,7 @@ export async function discoverBuzzRooms(params: { // Status callers must not wait for a fresh timeout at every relay phase. const timeoutSignal = AbortSignal.timeout(timeoutMs); const signal = params.signal ? AbortSignal.any([params.signal, timeoutSignal]) : timeoutSignal; - const relay = await connectAuthenticatedBuzzRelay({ + const { relay, relayPublicKey } = await connectAuthenticatedBuzzRelaySession({ relayUrl: params.relayUrl, secretKey, authTag: parseBuzzAuthTag(params.authTag ?? ""), @@ -162,6 +170,7 @@ export async function discoverBuzzRooms(params: { // discovery. Require the explicit Bot role before setup or probes accept a room. return await discoverBuzzRoomsOnRelay({ relay, + relayPublicKey, publicKey, timeoutMs, signal, diff --git a/extensions/buzz/src/room-membership-query.ts b/extensions/buzz/src/room-membership-query.ts index 1ba1666c553a..5017bacabc07 100644 --- a/extensions/buzz/src/room-membership-query.ts +++ b/extensions/buzz/src/room-membership-query.ts @@ -12,6 +12,7 @@ const MEMBERSHIP_QUERY_COMPLETE_REASON = "membership snapshot loaded"; async function queryBuzzRoomMembershipBatch(params: { relay: Relay; + relayPublicKey: string; channelIds: string[]; signal?: AbortSignal; }): Promise> { @@ -48,13 +49,14 @@ async function queryBuzzRoomMembershipBatch(params: { [ { kinds: [BUZZ_ROOM_MEMBERSHIP_KIND], + authors: [params.relayPublicKey], "#d": params.channelIds, limit: params.channelIds.length, }, ], { onevent: (event) => { - const membership = parseBuzzRoomMembershipEvent(event); + const membership = parseBuzzRoomMembershipEvent(event, params.relayPublicKey); if ( !membership || !configuredRooms.has(membership.roomId) || @@ -90,6 +92,7 @@ async function queryBuzzRoomMembershipBatch(params: { export async function queryBuzzRoomMemberships(params: { relay: Relay; + relayPublicKey: string; channelIds: string[]; signal?: AbortSignal; }): Promise> { diff --git a/extensions/buzz/src/room-membership.ts b/extensions/buzz/src/room-membership.ts index e079e20d15dc..598d464b066b 100644 --- a/extensions/buzz/src/room-membership.ts +++ b/extensions/buzz/src/room-membership.ts @@ -21,8 +21,11 @@ export type BuzzRoomMembership = { roles: ReadonlyMap; }; -export function parseBuzzRoomMembershipEvent(event: Event): BuzzRoomMembership | undefined { - if (event.kind !== BUZZ_ROOM_MEMBERSHIP_KIND) { +export function parseBuzzRoomMembershipEvent( + event: Event, + relayPublicKey: string, +): BuzzRoomMembership | undefined { + if (event.kind !== BUZZ_ROOM_MEMBERSHIP_KIND || event.pubkey.toLowerCase() !== relayPublicKey) { return undefined; } const roomId = event.tags