From bc9bf9e68234415f72c8537ea798e725f13a3c80 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:32:18 -0400 Subject: [PATCH] Enforce relay resolution for repost events --- js/nostr/publishHelpers.js | 196 ++++++++++++++++++++++++++++- js/nostrEventSchemas.js | 12 +- tests/nostr-boost-actions.test.mjs | 60 +++++++-- 3 files changed, 245 insertions(+), 23 deletions(-) diff --git a/js/nostr/publishHelpers.js b/js/nostr/publishHelpers.js index f3897e97..c86010eb 100644 --- a/js/nostr/publishHelpers.js +++ b/js/nostr/publishHelpers.js @@ -520,6 +520,7 @@ export async function repostEvent({ const cachedVideo = client?.allEvents?.get(normalizedId) || null; const cachedRaw = client?.rawEvents?.get(normalizedId) || null; + let resolvedRawEvent = cachedRaw; let authorPubkey = typeof options.authorPubkey === "string" && options.authorPubkey.trim() @@ -544,10 +545,126 @@ export async function repostEvent({ addressRelay = pointer.relay; } + const relayCandidates = new Set(); + + const rememberRelayCandidate = (value) => { + if (typeof value !== "string") { + return; + } + const normalized = value.trim(); + if (!normalized) { + return; + } + relayCandidates.add(normalized); + }; + + const rememberPointerRelay = (pointerCandidate) => { + const normalizedPointer = normalizePointerInput(pointerCandidate); + if (normalizedPointer?.relay) { + rememberRelayCandidate(normalizedPointer.relay); + } + }; + + const rememberRelayHintsFromObject = (candidate, depth = 0) => { + if (!candidate || typeof candidate !== "object" || depth > 3) { + return; + } + + const directKeys = [ + "relay", + "relayUrl", + "eventRelay", + "pointerRelay", + "sourceRelay", + "originRelay", + "addressRelay", + ]; + directKeys.forEach((key) => { + if (typeof candidate[key] === "string") { + rememberRelayCandidate(candidate[key]); + } + }); + + if (Array.isArray(candidate.relays)) { + candidate.relays.forEach((value) => { + rememberRelayCandidate(value); + }); + } + + if (candidate.pointer) { + rememberPointerRelay(candidate.pointer); + } + if (candidate.eventPointer) { + rememberPointerRelay(candidate.eventPointer); + } + + if (candidate.pointerInfo && depth <= 3) { + rememberRelayHintsFromObject(candidate.pointerInfo, depth + 1); + } + + if (Array.isArray(candidate.pointers)) { + candidate.pointers.forEach((pointerCandidate) => { + rememberPointerRelay(pointerCandidate); + }); + } + + if (candidate.video && depth <= 3) { + rememberRelayHintsFromObject(candidate.video, depth + 1); + } + }; + let eventRelay = typeof options.eventRelay === "string" ? options.eventRelay.trim() : ""; - if (!eventRelay && pointer?.type === "e" && pointer.relay) { - eventRelay = pointer.relay; + if (eventRelay) { + rememberRelayCandidate(eventRelay); + } + + if (pointer) { + rememberPointerRelay(pointer); + if (!eventRelay && pointer.type === "e" && pointer.relay) { + eventRelay = pointer.relay; + } + } + + rememberRelayHintsFromObject(options); + rememberRelayHintsFromObject(pointer); + rememberRelayHintsFromObject(cachedVideo); + rememberRelayHintsFromObject(cachedVideo?.pointerInfo); + rememberRelayHintsFromObject(cachedRaw); + + if (addressRelay) { + rememberRelayCandidate(addressRelay); + } + + const deriveRelayFromRawEvent = (event) => { + if (!event || typeof event !== "object") { + return ""; + } + const directRelay = + typeof event.relay === "string" ? event.relay.trim() : ""; + if (directRelay) { + return directRelay; + } + const relayCollections = [event.relays, event.seenOn, event.seen_on]; + for (const collection of relayCollections) { + if (!Array.isArray(collection)) { + continue; + } + for (const entry of collection) { + if (typeof entry === "string" && entry.trim()) { + return entry.trim(); + } + } + } + return ""; + }; + + const cachedRawRelay = deriveRelayFromRawEvent(cachedRaw); + if (cachedRawRelay) { + rememberRelayCandidate(cachedRawRelay); + if (!eventRelay) { + eventRelay = cachedRawRelay; + } } let targetKind = Number.isFinite(options.kind) @@ -642,7 +759,79 @@ export async function repostEvent({ : client?.relays, ); const relays = relaysOverride.length ? relaysOverride : Array.from(RELAY_URLS); - const chosenPublishRelay = relays.length ? relays[0] : ""; + const probeRelays = (() => { + const order = []; + if (eventRelay) { + order.push(eventRelay); + } + relayCandidates.forEach((relayUrl) => { + if (relayUrl && !order.includes(relayUrl)) { + order.push(relayUrl); + } + }); + relays.forEach((relayUrl) => { + if (relayUrl && !order.includes(relayUrl)) { + order.push(relayUrl); + } + }); + return order; + })(); + + if ((!eventRelay || !resolvedRawEvent) && typeof client?.fetchRawEventById === "function") { + for (const relayUrl of probeRelays) { + if (!relayUrl) { + continue; + } + if (!resolvedRawEvent) { + try { + const fetched = await client.fetchRawEventById(normalizedId, { + relays: [relayUrl], + }); + if (fetched) { + resolvedRawEvent = fetched; + } + } catch (error) { + devLogger.warn( + `[nostr] Failed to fetch ${normalizedId} from ${relayUrl} while preparing repost`, + error, + ); + } + } + + if (!eventRelay && resolvedRawEvent) { + eventRelay = relayUrl; + rememberRelayCandidate(relayUrl); + } + + if (eventRelay && resolvedRawEvent) { + break; + } + } + } + + if (resolvedRawEvent && !eventRelay) { + const rawRelay = deriveRelayFromRawEvent(resolvedRawEvent); + if (rawRelay) { + eventRelay = rawRelay; + rememberRelayCandidate(rawRelay); + } + } + + if (!eventRelay) { + devLogger.warn( + `[nostr] Repost aborted: missing source relay for ${normalizedId}`, + { candidates: probeRelays }, + ); + return { + ok: false, + error: "missing-event-relay", + details: { relays: probeRelays }, + }; + } + + devLogger.log( + `[nostr] Resolved repost target relay ${eventRelay} for ${normalizedId}`, + ); let actorPubkey = typeof options.actorPubkey === "string" && options.actorPubkey.trim() @@ -698,7 +887,6 @@ export async function repostEvent({ created_at: createdAt, eventId: normalizedId, eventRelay, - publishRelay: chosenPublishRelay, address, addressRelay, authorPubkey, diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 730a94bb..8f7e5372 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -651,7 +651,6 @@ export function buildRepostEvent({ created_at, eventId = "", eventRelay = "", - publishRelay = "", address = "", addressRelay = "", authorPubkey = "", @@ -665,11 +664,12 @@ export function buildRepostEvent({ const normalizedEventId = typeof eventId === "string" ? eventId.trim() : ""; const normalizedEventRelay = typeof eventRelay === "string" ? eventRelay.trim() : ""; - const normalizedPublishRelay = - typeof publishRelay === "string" ? publishRelay.trim() : ""; - const resolvedEventRelay = normalizedEventRelay || normalizedPublishRelay; - if (normalizedEventId && resolvedEventRelay) { - tags.push(["e", normalizedEventId, resolvedEventRelay]); + + if (normalizedEventId) { + if (!normalizedEventRelay) { + throw new Error("missing-event-relay"); + } + tags.push(["e", normalizedEventId, normalizedEventRelay]); } const normalizedAddress = typeof address === "string" ? address.trim() : ""; diff --git a/tests/nostr-boost-actions.test.mjs b/tests/nostr-boost-actions.test.mjs index 701400a4..4d34f269 100644 --- a/tests/nostr-boost-actions.test.mjs +++ b/tests/nostr-boost-actions.test.mjs @@ -67,6 +67,10 @@ function createPublishClient({ actorPubkey = "", sessionPubkey = "", failPublish ensurePool: async () => pool, ensureSessionActor: async () => sessionPubkey, ensureExtensionPermissions: async () => ({ ok: true }), + fetchRawEventById: async (id, { relays: requestedRelays } = {}) => { + void requestedRelays; + return client.rawEvents.get(id) || null; + }, countEventsAcrossRelays: async () => ({ total: 0, perRelay: [] }), }; @@ -100,20 +104,20 @@ function createPublishClient({ actorPubkey = "", sessionPubkey = "", failPublish assert.deepEqual(event.tags[2], ["p", "deadbeef"]); })(); -(function testBuildRepostEventFallsBackToPublishRelay() { +(function testBuildRepostEventRequiresRelay() { const createdAt = 1_700_000_001; - const event = buildRepostEvent({ - pubkey: "actorpubkey", - created_at: createdAt, - eventId: "event456", - publishRelay: "wss://relay.example", - authorPubkey: "cafebabe", - targetKind: 30078, - }); - assert.equal(event.kind, 16); - assert.deepEqual(event.tags[0], ["e", "event456", "wss://relay.example"]); - assert.deepEqual(event.tags[1], ["p", "cafebabe"]); + assert.throws( + () => + buildRepostEvent({ + pubkey: "actorpubkey", + created_at: createdAt, + eventId: "event456", + authorPubkey: "cafebabe", + targetKind: 30078, + }), + /missing-event-relay/, + ); })(); await (async function testRepostEventUsesSessionActorAndDerivesAddress() { @@ -122,7 +126,11 @@ await (async function testRepostEventUsesSessionActorAndDerivesAddress() { const sessionPubkey = "a".repeat(64); const { client } = createPublishClient({ sessionPubkey }); - client.rawEvents.set(eventId, { id: eventId, pubkey: authorPubkey }); + client.rawEvents.set(eventId, { + id: eventId, + pubkey: authorPubkey, + relays: ["wss://relay.example"], + }); client.allEvents.set(eventId, { id: eventId, pubkey: authorPubkey, @@ -194,6 +202,32 @@ await (async function testRepostEventHandlesPublishFailure() { assert.ok(result.details instanceof Error); })(); +await (async function testRepostEventFailsWhenRelayMissing() { + const eventId = "repost-no-relay"; + const authorPubkey = "c".repeat(64); + const sessionPubkey = "e".repeat(64); + const { client } = createPublishClient({ sessionPubkey }); + + client.fetchRawEventById = async () => null; + + const result = await repostEvent({ + client, + eventId, + options: { + pointer: ["e", eventId], + authorPubkey, + }, + resolveActiveSigner: resolveActiveSignerStub, + shouldRequestExtensionPermissions: shouldRequestExtensionPermissionsStub, + signEventWithPrivateKey: signEventWithPrivateKeyStub, + eventToAddressPointer: eventToAddressPointerStub, + }); + + assert.equal(result.ok, false); + assert.equal(result.error, "missing-event-relay"); + assert.ok(Array.isArray(result.details?.relays)); +})(); + await (async function testMirrorEventIncludesHostedMetadata() { const eventId = "mirror-event"; const actorPubkey = "1".repeat(64);